1#![forbid(unsafe_code)]
6
7use jerrycan_core::{App, Error, Extension, Result};
8use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait};
9
10pub const MIGRATION_ADVISORY_KEY: i64 = 0x6A_43_6D_69_67_00_00_01;
19
20pub use sea_orm;
24pub use sea_query;
25pub use sea_query_binder;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Backend {
31 Sqlite,
32 Postgres,
33}
34
35#[derive(Clone)]
38pub struct Db {
39 conn: DatabaseConnection,
40 backend: Backend,
41 url: String,
42}
43
44impl Db {
45 pub async fn connect(url: &str) -> Result<Self> {
47 let backend = if url.starts_with("postgres") {
48 Backend::Postgres
49 } else if url.starts_with("sqlite") {
50 Backend::Sqlite
51 } else {
52 return Err(Error::internal(format!(
53 "unsupported database url scheme: `{url}` (sqlite:// or postgres:// in v0)"
54 )));
55 };
56 let max = match backend {
59 Backend::Sqlite => 1,
60 Backend::Postgres => 5,
61 };
62 let mut opts = sea_orm::ConnectOptions::new(url.to_string());
63 opts.max_connections(max);
64 let conn = Database::connect(opts).await.map_err(db_error)?;
65 Ok(Self {
66 conn,
67 backend,
68 url: url.to_string(),
69 })
70 }
71
72 pub async fn from_env() -> Result<Self> {
74 let url = std::env::var("JERRYCAN_DATABASE_URL")
75 .unwrap_or_else(|_| "sqlite::memory:".to_string());
76 Self::connect(&url).await
77 }
78
79 pub fn conn(&self) -> &DatabaseConnection {
82 &self.conn
83 }
84
85 pub fn backend(&self) -> Backend {
86 self.backend
87 }
88
89 pub fn url(&self) -> &str {
93 &self.url
94 }
95
96 pub fn sql(&self, query: &str) -> String {
98 translate_placeholders(query, self.backend)
99 }
100
101 pub fn query_builder(&self) -> &'static dyn sea_query::QueryBuilder {
105 match self.backend {
106 Backend::Sqlite => &sea_query::SqliteQueryBuilder,
107 Backend::Postgres => &sea_query::PostgresQueryBuilder,
108 }
109 }
110
111 fn backend_db(&self) -> sea_orm::DatabaseBackend {
114 match self.backend {
115 Backend::Sqlite => sea_orm::DatabaseBackend::Sqlite,
116 Backend::Postgres => sea_orm::DatabaseBackend::Postgres,
117 }
118 }
119}
120
121#[derive(Debug, Clone, Copy)]
124pub struct Migration {
125 pub name: &'static str,
126 pub sqlite: &'static str,
127 pub postgres: &'static str,
128}
129
130#[derive(Debug, Clone)]
133pub struct OwnedMigration {
134 pub name: String,
135 pub sqlite: String,
136 pub postgres: String,
137}
138
139impl Db {
140 pub async fn migrate(&self, migrations: &[Migration]) -> Result<Vec<String>> {
147 self.migrate_iter(migrations.iter().map(|m| (m.name, m.sqlite, m.postgres)))
148 .await
149 }
150
151 pub async fn migrate_owned(&self, migrations: &[OwnedMigration]) -> Result<Vec<String>> {
153 self.migrate_iter(
154 migrations
155 .iter()
156 .map(|m| (m.name.as_str(), m.sqlite.as_str(), m.postgres.as_str())),
157 )
158 .await
159 }
160
161 async fn migrate_iter<'a>(
167 &self,
168 items: impl Iterator<Item = (&'a str, &'a str, &'a str)>,
169 ) -> Result<Vec<String>> {
170 let txn = self.conn.begin().await.map_err(db_error)?;
175
176 if self.backend == Backend::Postgres {
177 txn.execute(Statement::from_string(
181 sea_orm::DatabaseBackend::Postgres,
182 format!("SELECT pg_advisory_xact_lock({MIGRATION_ADVISORY_KEY})"),
183 ))
184 .await
185 .map_err(db_error)?;
186 }
187
188 txn.execute_unprepared(
189 "CREATE TABLE IF NOT EXISTS _jerrycan_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)",
190 )
191 .await
192 .map_err(db_error)?;
193
194 let mut applied = Vec::new();
195 for (name, sqlite, postgres) in items {
196 let seen = txn
197 .query_one(Statement::from_sql_and_values(
198 self.backend_db(),
199 self.sql("SELECT name FROM _jerrycan_migrations WHERE name = ?"),
200 [name.into()],
201 ))
202 .await
203 .map_err(db_error)?;
204 if seen.is_some() {
205 continue;
206 }
207 let statement = match self.backend {
208 Backend::Sqlite => sqlite,
209 Backend::Postgres => postgres,
210 };
211 txn.execute_unprepared(statement).await.map_err(|e| {
212 eprintln!("jerrycan-db: migration `{name}` failed");
213 db_error(e)
214 })?;
215 txn.execute(Statement::from_sql_and_values(
216 self.backend_db(),
217 self.sql("INSERT INTO _jerrycan_migrations (name, applied_at) VALUES (?, ?)"),
218 [name.into(), chrono_free_timestamp().into()],
219 ))
220 .await
221 .map_err(db_error)?;
222 applied.push(name.to_string());
223 }
224 txn.commit().await.map_err(db_error)?;
225 Ok(applied)
226 }
227}
228
229fn chrono_free_timestamp() -> String {
231 let secs = std::time::SystemTime::now()
232 .duration_since(std::time::UNIX_EPOCH)
233 .map(|d| d.as_secs())
234 .unwrap_or(0);
235 format!("unix:{secs}")
236}
237
238pub fn translate_placeholders(query: &str, backend: Backend) -> String {
241 match backend {
242 Backend::Sqlite => query.to_string(),
243 Backend::Postgres => {
244 let mut out = String::with_capacity(query.len() + 8);
245 let mut n = 0;
246 for ch in query.chars() {
247 if ch == '?' {
248 n += 1;
249 out.push('$');
250 out.push_str(&n.to_string());
251 } else {
252 out.push(ch);
253 }
254 }
255 out
256 }
257 }
258}
259
260pub fn db_error(e: sea_orm::DbErr) -> Error {
265 eprintln!("jerrycan-db: {e}");
266 if matches!(
267 e.sql_err(),
268 Some(sea_orm::SqlErr::UniqueConstraintViolation(_))
269 ) {
270 return Error::conflict("conflict: a row with this key already exists");
271 }
272 Error::new(
273 jerrycan_core::http::StatusCode::INTERNAL_SERVER_ERROR,
274 "JC0510",
275 "database error",
276 )
277}
278
279impl Extension for Db {
280 fn register(self, app: App) -> App {
281 app.provide(self)
282 }
283}
284
285pub use sqlx;
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[tokio::test]
294 async fn db_exposes_its_connection_url() {
295 let db = Db::connect("sqlite::memory:").await.unwrap();
296 assert_eq!(db.url(), "sqlite::memory:");
297 }
298
299 #[tokio::test]
300 async fn connects_and_executes_via_sea_orm() {
301 let db = Db::connect("sqlite::memory:").await.unwrap();
304 assert_eq!(db.backend(), Backend::Sqlite);
305 db.conn()
306 .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
307 .await
308 .unwrap();
309 }
310
311 #[test]
312 fn placeholder_translation_is_backend_aware() {
313 assert_eq!(
314 translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Postgres),
315 "INSERT INTO t (a, b) VALUES ($1, $2)"
316 );
317 assert_eq!(
318 translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Sqlite),
319 "INSERT INTO t (a, b) VALUES (?, ?)"
320 );
321 }
322
323 #[tokio::test]
324 async fn from_env_defaults_to_sqlite_memory() {
325 let db = Db::from_env().await.unwrap();
327 assert_eq!(db.backend(), Backend::Sqlite);
328 }
329
330 #[test]
331 fn db_errors_are_jc0510_and_leak_nothing() {
332 let e = db_error(sea_orm::DbErr::Custom("boom".into()));
333 assert_eq!(e.code(), "JC0510");
334 assert_eq!(e.message(), "database error");
335 }
336
337 #[tokio::test]
341 async fn sea_query_builds_and_executes_via_the_connection() {
342 use sea_query::{Alias, Expr, Query};
343
344 let db = Db::connect("sqlite::memory:").await.unwrap();
345 db.conn()
346 .execute_unprepared("CREATE TABLE sq (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
347 .await
348 .unwrap();
349
350 let (sql, values) = Query::insert()
351 .into_table(Alias::new("sq"))
352 .columns([Alias::new("id"), Alias::new("title")])
353 .values_panic([7.into(), "hello".into()])
354 .returning(Query::returning().columns([Alias::new("id")]))
355 .build_any(db.query_builder());
356 let row = db
357 .conn()
358 .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
359 .await
360 .unwrap()
361 .expect("RETURNING id row");
362 assert_eq!(
363 row.try_get::<i64>("", "id").unwrap(),
364 7,
365 "RETURNING id round-trips"
366 );
367
368 let (sql, values) = Query::select()
369 .columns([Alias::new("id"), Alias::new("title")])
370 .from(Alias::new("sq"))
371 .and_where(Expr::col(Alias::new("id")).eq(7))
372 .build_any(db.query_builder());
373 let row = db
374 .conn()
375 .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
376 .await
377 .unwrap()
378 .expect("select row");
379 assert_eq!(row.try_get::<String>("", "title").unwrap(), "hello");
380 }
381
382 #[tokio::test]
385 async fn unique_violations_map_to_409_conflict() {
386 let db = Db::connect("sqlite::memory:").await.unwrap();
387 db.conn()
388 .execute_unprepared("CREATE TABLE u (id INTEGER PRIMARY KEY, t TEXT)")
389 .await
390 .unwrap();
391 db.conn()
392 .execute_unprepared("INSERT INTO u VALUES (1, 'a')")
393 .await
394 .unwrap();
395 let dup = db
396 .conn()
397 .execute_unprepared("INSERT INTO u VALUES (1, 'b')")
398 .await
399 .expect_err("duplicate pk must fail");
400 let e = db_error(dup);
401 assert_eq!(e.code(), "JC0409");
402 assert_eq!(e.status().as_u16(), 409);
403 assert!(!e.message().contains("sqlite"), "{}", e.message());
405 }
406
407 fn demo_migrations() -> Vec<Migration> {
408 vec![
409 Migration {
410 name: "0001_create_todos",
411 sqlite: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)",
412 postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)",
413 },
414 Migration {
415 name: "0002_add_done",
416 sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0",
417 postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE",
418 },
419 ]
420 }
421
422 #[tokio::test]
423 async fn migrations_apply_in_order_and_only_once() {
424 let db = Db::connect("sqlite::memory:").await.unwrap();
425 let applied = db.migrate(&demo_migrations()).await.unwrap();
426 assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
427
428 let applied = db.migrate(&demo_migrations()).await.unwrap();
430 assert!(applied.is_empty());
431
432 db.conn()
434 .execute_unprepared("INSERT INTO todos (title, done) VALUES ('x', 1)")
435 .await
436 .unwrap();
437 }
438
439 #[tokio::test]
440 async fn owned_migrations_apply_in_order_and_only_once() {
441 let db = Db::connect("sqlite::memory:").await.unwrap();
442 let owned = vec![
443 OwnedMigration {
444 name: "0001_create_todos".into(),
445 sqlite:
446 "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)"
447 .into(),
448 postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)"
449 .into(),
450 },
451 OwnedMigration {
452 name: "0002_add_done".into(),
453 sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0".into(),
454 postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE".into(),
455 },
456 ];
457 let applied = db.migrate_owned(&owned).await.unwrap();
458 assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
459 let applied = db.migrate_owned(&owned).await.unwrap();
461 assert!(applied.is_empty());
462 }
463
464 #[tokio::test]
469 async fn transactions_roll_back_on_error() {
470 use sea_orm::TransactionTrait;
471 let db = Db::connect("sqlite::memory:").await.unwrap();
472 db.conn()
473 .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
474 .await
475 .unwrap();
476 let r = db
477 .conn()
478 .transaction::<_, (), sea_orm::DbErr>(|txn| {
479 Box::pin(async move {
480 txn.execute_unprepared("INSERT INTO t VALUES (1)").await?;
481 Err(sea_orm::DbErr::Custom("boom".into()))
482 })
483 })
484 .await;
485 assert!(r.is_err());
486 let rows = db
487 .conn()
488 .query_all(sea_orm::Statement::from_string(
489 sea_orm::DatabaseBackend::Sqlite,
490 "SELECT id FROM t",
491 ))
492 .await
493 .unwrap();
494 assert!(rows.is_empty(), "rollback must leave no rows");
495 }
496
497 #[tokio::test]
498 async fn a_failing_migration_surfaces_jc0510_and_is_not_recorded() {
499 let db = Db::connect("sqlite::memory:").await.unwrap();
500 let bad = vec![Migration {
501 name: "0001_broken",
502 sqlite: "CREATE GARBAGE",
503 postgres: "CREATE GARBAGE",
504 }];
505 let err = db.migrate(&bad).await.unwrap_err();
506 assert_eq!(err.code(), "JC0510");
507
508 let good = vec![Migration {
510 name: "0001_broken",
511 sqlite: "CREATE TABLE ok (x BIGINT)",
512 postgres: "CREATE TABLE ok (x BIGINT)",
513 }];
514 let applied = db.migrate(&good).await.unwrap();
515 assert_eq!(applied, vec!["0001_broken"]);
516 }
517
518 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
525 #[ignore = "needs a local postgres (set JERRYCAN_TEST_PG_URL)"]
526 async fn concurrent_migrators_do_not_race() {
527 let Ok(url) = std::env::var("JERRYCAN_TEST_PG_URL") else {
528 eprintln!("SKIP: JERRYCAN_TEST_PG_URL not set");
529 return;
530 };
531 let nanos = std::time::SystemTime::now()
534 .duration_since(std::time::UNIX_EPOCH)
535 .unwrap()
536 .as_nanos();
537 let table = format!("mig_race_{nanos}");
538 let name = format!("{table}_0001");
539 let migrations = vec![Migration {
540 name: Box::leak(name.clone().into_boxed_str()),
541 sqlite: "",
542 postgres: Box::leak(
543 format!("CREATE TABLE {table} (id BIGSERIAL PRIMARY KEY, v TEXT NOT NULL)")
544 .into_boxed_str(),
545 ),
546 }];
547 let migrations = std::sync::Arc::new(migrations);
548
549 let mut handles = Vec::new();
551 for _ in 0..8 {
552 let url = url.clone();
553 let migrations = migrations.clone();
554 handles.push(tokio::spawn(async move {
555 let db = Db::connect(&url).await.expect("connect");
556 db.migrate(&migrations).await
557 }));
558 }
559
560 let mut total_applied = 0usize;
561 for h in handles {
562 let applied = h.await.expect("task").expect("migrate must not error");
563 total_applied += applied.len();
564 }
565 assert_eq!(
566 total_applied, 1,
567 "exactly one migrator applies the migration; the rest see it recorded"
568 );
569
570 let db = Db::connect(&url).await.unwrap();
572 db.conn()
573 .execute_unprepared(&format!("INSERT INTO {table} (v) VALUES ('ok')"))
574 .await
575 .unwrap();
576 db.conn()
577 .execute_unprepared(&format!("DROP TABLE {table}"))
578 .await
579 .unwrap();
580 }
581}