1pub mod auth;
29pub mod events;
30pub mod mail;
31pub mod uow;
32
33use puerto_core::RepositoryError;
34use sqlx::migrate::Migrator;
35use sqlx::postgres::{PgConnectOptions, PgConnection, PgPoolOptions};
36use sqlx::{Connection, PgPool};
37use std::time::Duration;
38
39pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
42
43pub const SENT_MAILS_MIGRATION: &str = include_str!("../migrations/sent_mails.sql");
46
47pub const USERS_MIGRATION: &str = include_str!("../migrations/users.sql");
51
52pub const AUTH_MIGRATION: &str = include_str!("../migrations/auth.sql");
55
56#[derive(Debug, thiserror::Error)]
59#[non_exhaustive]
60pub enum DatabaseError {
61 #[error("cannot connect to the database at {target}: {cause}")]
63 ConnectionError { target: String, cause: String },
64 #[error("cannot apply the database migrations: {cause}")]
65 MigrationError { cause: String },
66}
67
68pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
69 let failed = |e: sqlx::Error| DatabaseError::ConnectionError {
70 target: describe_url(database_url),
71 cause: e.to_string(),
72 };
73 let options: PgConnectOptions = database_url.parse().map_err(failed)?;
74 let probe = PgConnection::connect_with(&options).await.map_err(failed)?;
77 let _ = probe.close().await;
78 Ok(PgPoolOptions::new()
79 .max_connections(5)
80 .acquire_timeout(Duration::from_secs(30))
81 .connect_lazy_with(options))
82}
83
84fn describe_url(url: &str) -> String {
87 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
88 let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
89 rest.split('?').next().unwrap_or(rest).to_string()
90}
91
92pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
94 migrator
95 .run(pool)
96 .await
97 .map_err(|e| DatabaseError::MigrationError {
98 cause: e.to_string(),
99 })
100}
101
102pub async fn ping(pool: &PgPool) -> bool {
107 sqlx::query("SELECT 1").execute(pool).await.is_ok()
108}
109
110pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
114 map_sqlx_error_with(err, &[])
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ForeignKey {
121 pub constraint: &'static str,
122 pub field: &'static str,
123}
124
125pub fn map_sqlx_error_with(err: &sqlx::Error, references: &[ForeignKey]) -> RepositoryError {
129 let Some(db_err) = err.as_database_error() else {
130 return RepositoryError::Unavailable;
131 };
132 match db_err.code().as_deref() {
133 Some("23505") => RepositoryError::Duplicate,
135 Some("23503") => db_err
137 .constraint()
138 .and_then(|name| references.iter().find(|fk| fk.constraint == name))
139 .map_or(RepositoryError::Unavailable, |fk| {
140 RepositoryError::MissingReference(fk.field)
141 }),
142 _ => RepositoryError::Unavailable,
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn unreachable_pool() -> PgPool {
152 PgPoolOptions::new()
153 .acquire_timeout(Duration::from_millis(200))
154 .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
155 .unwrap()
156 }
157
158 #[derive(Debug)]
160 struct FakeDbError {
161 code: &'static str,
162 constraint: Option<&'static str>,
163 }
164
165 impl std::fmt::Display for FakeDbError {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 write!(f, "SQLSTATE {}", self.code)
168 }
169 }
170
171 impl std::error::Error for FakeDbError {}
172
173 impl sqlx::error::DatabaseError for FakeDbError {
174 fn message(&self) -> &str {
175 self.code
176 }
177 fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
178 Some(self.code.into())
179 }
180 fn constraint(&self) -> Option<&str> {
181 self.constraint
182 }
183 fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
184 self
185 }
186 fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
187 self
188 }
189 fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
190 self
191 }
192 fn kind(&self) -> sqlx::error::ErrorKind {
193 sqlx::error::ErrorKind::Other
194 }
195 }
196
197 fn database_error(code: &'static str) -> sqlx::Error {
198 sqlx::Error::Database(Box::new(FakeDbError {
199 code,
200 constraint: None,
201 }))
202 }
203
204 fn constraint_error(code: &'static str, constraint: &'static str) -> sqlx::Error {
205 sqlx::Error::Database(Box::new(FakeDbError {
206 code,
207 constraint: Some(constraint),
208 }))
209 }
210
211 const ORDER_REFERENCES: &[ForeignKey] = &[
212 ForeignKey {
213 constraint: "orders_customer_id_fkey",
214 field: "customer_id",
215 },
216 ForeignKey {
217 constraint: "orders_reviewer_id_fkey",
218 field: "reviewer_id",
219 },
220 ];
221
222 #[test]
223 fn should_map_a_foreign_key_violation_to_the_missing_field() {
224 assert_eq!(
225 map_sqlx_error_with(
226 &constraint_error("23503", "orders_reviewer_id_fkey"),
227 ORDER_REFERENCES
228 ),
229 RepositoryError::MissingReference("reviewer_id")
230 );
231 }
232
233 #[test]
234 fn should_map_a_foreign_key_violation_on_an_unlisted_constraint_to_unavailable() {
235 assert_eq!(
236 map_sqlx_error_with(
237 &constraint_error("23503", "orders_warehouse_id_fkey"),
238 ORDER_REFERENCES
239 ),
240 RepositoryError::Unavailable
241 );
242 }
243
244 #[test]
245 fn should_map_a_unique_violation_to_duplicate_whatever_the_references() {
246 assert_eq!(
247 map_sqlx_error_with(
248 &constraint_error("23505", "orders_customer_id_fkey"),
249 ORDER_REFERENCES
250 ),
251 RepositoryError::Duplicate
252 );
253 }
254
255 #[test]
256 fn should_map_a_non_database_error_to_unavailable_whatever_the_references() {
257 assert_eq!(
258 map_sqlx_error_with(&sqlx::Error::PoolTimedOut, ORDER_REFERENCES),
259 RepositoryError::Unavailable
260 );
261 }
262
263 #[test]
264 fn should_map_a_unique_violation_to_duplicate() {
265 assert_eq!(
266 map_sqlx_error(&database_error("23505")),
267 RepositoryError::Duplicate
268 );
269 }
270
271 #[test]
272 fn should_map_another_database_error_to_unavailable() {
273 assert_eq!(
275 map_sqlx_error(&database_error("23503")),
276 RepositoryError::Unavailable
277 );
278 }
279
280 #[test]
281 fn should_map_a_non_database_error_to_unavailable() {
282 assert_eq!(
283 map_sqlx_error(&sqlx::Error::PoolTimedOut),
284 RepositoryError::Unavailable
285 );
286 }
287
288 #[tokio::test]
289 async fn should_report_not_ready_when_the_database_is_unreachable() {
290 assert!(!ping(&unreachable_pool()).await);
291 }
292
293 #[tokio::test]
294 async fn should_reject_a_database_url_that_is_not_one() {
295 let result = create_postgres_pool("not a database url").await;
296
297 assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
298 }
299
300 #[tokio::test]
301 async fn should_say_where_and_why_the_connection_failed_without_the_password() {
302 let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
304
305 let started = std::time::Instant::now();
307 let message = create_postgres_pool(url).await.unwrap_err().to_string();
308
309 assert!(message.contains("127.0.0.1:1/shop"), "{message}");
311 assert!(message.to_lowercase().contains("refused"), "{message}");
312 assert!(!message.contains("s3cret"), "{message}");
313 assert!(!message.contains("devuser"), "{message}");
314 assert!(
316 started.elapsed() < Duration::from_secs(5),
317 "{:?}",
318 started.elapsed()
319 );
320 }
321
322 #[test]
323 fn should_describe_a_database_url_by_its_host_and_database_only() {
324 assert_eq!(
325 describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
326 "db.internal:5432/shop"
327 );
328 assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
329 assert_eq!(describe_url("not a database url"), "not a database url");
330 }
331
332 async fn test_database() -> Option<PgPool> {
335 let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
336 Some(
337 create_postgres_pool(&format!("{server}/postgres"))
338 .await
339 .unwrap(),
340 )
341 }
342
343 #[tokio::test]
344 #[ignore = "needs Postgres: make test/db"]
345 async fn postgres_unique_violation_maps_to_duplicate() {
346 let Some(pool) = test_database().await else {
347 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
348 return;
349 };
350 let mut conn = pool.acquire().await.unwrap();
351 sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
353 .execute(&mut *conn)
354 .await
355 .unwrap();
356 sqlx::query("INSERT INTO skus VALUES ('A-1')")
357 .execute(&mut *conn)
358 .await
359 .unwrap();
360
361 let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
362 .execute(&mut *conn)
363 .await
364 .unwrap_err();
365
366 assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
367 }
368
369 #[tokio::test]
370 #[ignore = "needs Postgres: make test/db"]
371 async fn postgres_foreign_key_violation_maps_to_the_missing_field() {
372 let Some(pool) = test_database().await else {
373 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
374 return;
375 };
376 let mut conn = pool.acquire().await.unwrap();
377 sqlx::query("CREATE TEMPORARY TABLE customers (id UUID PRIMARY KEY)")
378 .execute(&mut *conn)
379 .await
380 .unwrap();
381 sqlx::query(
382 "CREATE TEMPORARY TABLE orders (customer_id UUID NOT NULL \
383 CONSTRAINT orders_customer_id_fkey REFERENCES customers (id))",
384 )
385 .execute(&mut *conn)
386 .await
387 .unwrap();
388
389 let err = sqlx::query("INSERT INTO orders VALUES (gen_random_uuid())")
390 .execute(&mut *conn)
391 .await
392 .unwrap_err();
393
394 assert_eq!(
395 map_sqlx_error_with(&err, ORDER_REFERENCES),
396 RepositoryError::MissingReference("customer_id")
397 );
398 }
399
400 #[tokio::test]
401 #[ignore = "needs Postgres: make test/db"]
402 async fn postgres_ping_reports_ready() {
403 let Some(pool) = test_database().await else {
404 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
405 return;
406 };
407
408 assert!(ping(&pool).await);
409 }
410}