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 if backend == Backend::Sqlite {
65 opts.map_sqlx_sqlite_opts(|o| o.foreign_keys(true));
72 }
73 let conn = Database::connect(opts).await.map_err(db_error)?;
74 Ok(Self {
75 conn,
76 backend,
77 url: url.to_string(),
78 })
79 }
80
81 pub async fn from_env() -> Result<Self> {
83 let url = std::env::var("JERRYCAN_DATABASE_URL")
84 .unwrap_or_else(|_| "sqlite::memory:".to_string());
85 Self::connect(&url).await
86 }
87
88 pub fn conn(&self) -> &DatabaseConnection {
91 &self.conn
92 }
93
94 pub fn backend(&self) -> Backend {
95 self.backend
96 }
97
98 pub fn url(&self) -> &str {
102 &self.url
103 }
104
105 pub fn sql(&self, query: &str) -> String {
107 translate_placeholders(query, self.backend)
108 }
109
110 pub fn query_builder(&self) -> &'static dyn sea_query::QueryBuilder {
114 match self.backend {
115 Backend::Sqlite => &sea_query::SqliteQueryBuilder,
116 Backend::Postgres => &sea_query::PostgresQueryBuilder,
117 }
118 }
119
120 fn backend_db(&self) -> sea_orm::DatabaseBackend {
123 match self.backend {
124 Backend::Sqlite => sea_orm::DatabaseBackend::Sqlite,
125 Backend::Postgres => sea_orm::DatabaseBackend::Postgres,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy)]
133pub struct Migration {
134 pub name: &'static str,
135 pub sqlite: &'static str,
136 pub postgres: &'static str,
137}
138
139#[derive(Debug, Clone)]
142pub struct OwnedMigration {
143 pub name: String,
144 pub sqlite: String,
145 pub postgres: String,
146}
147
148impl Db {
149 pub async fn migrate(&self, migrations: &[Migration]) -> Result<Vec<String>> {
156 self.migrate_iter(migrations.iter().map(|m| (m.name, m.sqlite, m.postgres)))
157 .await
158 }
159
160 pub async fn migrate_owned(&self, migrations: &[OwnedMigration]) -> Result<Vec<String>> {
162 self.migrate_iter(
163 migrations
164 .iter()
165 .map(|m| (m.name.as_str(), m.sqlite.as_str(), m.postgres.as_str())),
166 )
167 .await
168 }
169
170 async fn migrate_iter<'a>(
176 &self,
177 items: impl Iterator<Item = (&'a str, &'a str, &'a str)>,
178 ) -> Result<Vec<String>> {
179 let txn = self.conn.begin().await.map_err(db_error)?;
184
185 if self.backend == Backend::Postgres {
186 txn.execute(Statement::from_string(
190 sea_orm::DatabaseBackend::Postgres,
191 format!("SELECT pg_advisory_xact_lock({MIGRATION_ADVISORY_KEY})"),
192 ))
193 .await
194 .map_err(db_error)?;
195 }
196
197 txn.execute_unprepared(
198 "CREATE TABLE IF NOT EXISTS _jerrycan_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)",
199 )
200 .await
201 .map_err(db_error)?;
202
203 let mut applied = Vec::new();
204 for (name, sqlite, postgres) in items {
205 let seen = txn
206 .query_one(Statement::from_sql_and_values(
207 self.backend_db(),
208 self.sql("SELECT name FROM _jerrycan_migrations WHERE name = ?"),
209 [name.into()],
210 ))
211 .await
212 .map_err(db_error)?;
213 if seen.is_some() {
214 continue;
215 }
216 let statement = match self.backend {
217 Backend::Sqlite => sqlite,
218 Backend::Postgres => postgres,
219 };
220 txn.execute_unprepared(statement).await.map_err(|e| {
221 eprintln!("jerrycan-db: migration `{name}` failed");
222 db_error(e)
223 })?;
224 txn.execute(Statement::from_sql_and_values(
225 self.backend_db(),
226 self.sql("INSERT INTO _jerrycan_migrations (name, applied_at) VALUES (?, ?)"),
227 [name.into(), chrono_free_timestamp().into()],
228 ))
229 .await
230 .map_err(db_error)?;
231 applied.push(name.to_string());
232 }
233 txn.commit().await.map_err(db_error)?;
234 Ok(applied)
235 }
236}
237
238fn chrono_free_timestamp() -> String {
240 let secs = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .map(|d| d.as_secs())
243 .unwrap_or(0);
244 format!("unix:{secs}")
245}
246
247pub fn translate_placeholders(query: &str, backend: Backend) -> String {
250 match backend {
251 Backend::Sqlite => query.to_string(),
252 Backend::Postgres => {
253 let mut out = String::with_capacity(query.len() + 8);
254 let mut n = 0;
255 for ch in query.chars() {
256 if ch == '?' {
257 n += 1;
258 out.push('$');
259 out.push_str(&n.to_string());
260 } else {
261 out.push(ch);
262 }
263 }
264 out
265 }
266 }
267}
268
269pub fn db_error(e: sea_orm::DbErr) -> Error {
274 eprintln!("jerrycan-db: {e}");
275 if matches!(
276 e.sql_err(),
277 Some(sea_orm::SqlErr::UniqueConstraintViolation(_))
278 ) {
279 return Error::conflict("conflict: a row with this key already exists");
280 }
281 Error::new(
282 jerrycan_core::http::StatusCode::INTERNAL_SERVER_ERROR,
283 "JC0510",
284 "database error",
285 )
286}
287
288impl Extension for Db {
289 fn register(self, app: App) -> App {
290 app.provide(self)
291 }
292}
293
294pub use sqlx;
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[tokio::test]
303 async fn db_exposes_its_connection_url() {
304 let db = Db::connect("sqlite::memory:").await.unwrap();
305 assert_eq!(db.url(), "sqlite::memory:");
306 }
307
308 #[tokio::test]
309 async fn connects_and_executes_via_sea_orm() {
310 let db = Db::connect("sqlite::memory:").await.unwrap();
313 assert_eq!(db.backend(), Backend::Sqlite);
314 db.conn()
315 .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
316 .await
317 .unwrap();
318 }
319
320 #[test]
321 fn placeholder_translation_is_backend_aware() {
322 assert_eq!(
323 translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Postgres),
324 "INSERT INTO t (a, b) VALUES ($1, $2)"
325 );
326 assert_eq!(
327 translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Sqlite),
328 "INSERT INTO t (a, b) VALUES (?, ?)"
329 );
330 }
331
332 #[tokio::test]
333 async fn from_env_defaults_to_sqlite_memory() {
334 let db = Db::from_env().await.unwrap();
336 assert_eq!(db.backend(), Backend::Sqlite);
337 }
338
339 #[test]
340 fn db_errors_are_jc0510_and_leak_nothing() {
341 let e = db_error(sea_orm::DbErr::Custom("boom".into()));
342 assert_eq!(e.code(), "JC0510");
343 assert_eq!(e.message(), "database error");
344 }
345
346 #[tokio::test]
350 async fn sea_query_builds_and_executes_via_the_connection() {
351 use sea_query::{Alias, Expr, Query};
352
353 let db = Db::connect("sqlite::memory:").await.unwrap();
354 db.conn()
355 .execute_unprepared("CREATE TABLE sq (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
356 .await
357 .unwrap();
358
359 let (sql, values) = Query::insert()
360 .into_table(Alias::new("sq"))
361 .columns([Alias::new("id"), Alias::new("title")])
362 .values_panic([7.into(), "hello".into()])
363 .returning(Query::returning().columns([Alias::new("id")]))
364 .build_any(db.query_builder());
365 let row = db
366 .conn()
367 .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
368 .await
369 .unwrap()
370 .expect("RETURNING id row");
371 assert_eq!(
372 row.try_get::<i64>("", "id").unwrap(),
373 7,
374 "RETURNING id round-trips"
375 );
376
377 let (sql, values) = Query::select()
378 .columns([Alias::new("id"), Alias::new("title")])
379 .from(Alias::new("sq"))
380 .and_where(Expr::col(Alias::new("id")).eq(7))
381 .build_any(db.query_builder());
382 let row = db
383 .conn()
384 .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
385 .await
386 .unwrap()
387 .expect("select row");
388 assert_eq!(row.try_get::<String>("", "title").unwrap(), "hello");
389 }
390
391 #[tokio::test]
394 async fn unique_violations_map_to_409_conflict() {
395 let db = Db::connect("sqlite::memory:").await.unwrap();
396 db.conn()
397 .execute_unprepared("CREATE TABLE u (id INTEGER PRIMARY KEY, t TEXT)")
398 .await
399 .unwrap();
400 db.conn()
401 .execute_unprepared("INSERT INTO u VALUES (1, 'a')")
402 .await
403 .unwrap();
404 let dup = db
405 .conn()
406 .execute_unprepared("INSERT INTO u VALUES (1, 'b')")
407 .await
408 .expect_err("duplicate pk must fail");
409 let e = db_error(dup);
410 assert_eq!(e.code(), "JC0409");
411 assert_eq!(e.status().as_u16(), 409);
412 assert!(!e.message().contains("sqlite"), "{}", e.message());
414 }
415
416 #[tokio::test]
424 async fn sqlite_foreign_keys_are_enforced_through_the_pool() {
425 let db = Db::connect("sqlite::memory:").await.unwrap();
426
427 let row = db
429 .conn()
430 .query_one(Statement::from_string(
431 sea_orm::DatabaseBackend::Sqlite,
432 "PRAGMA foreign_keys",
433 ))
434 .await
435 .unwrap()
436 .expect("PRAGMA foreign_keys returns a row");
437 let on: i64 = row
438 .try_get::<i64>("", "foreign_keys")
439 .or_else(|_| row.try_get::<i32>("", "foreign_keys").map(i64::from))
440 .unwrap();
441 assert_eq!(on, 1, "foreign_keys must be ON through the pool");
442
443 db.conn()
444 .execute_unprepared("CREATE TABLE parents (id INTEGER PRIMARY KEY)")
445 .await
446 .unwrap();
447 db.conn()
448 .execute_unprepared(
449 "CREATE TABLE children (id INTEGER PRIMARY KEY, \
450 parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE)",
451 )
452 .await
453 .unwrap();
454 db.conn()
455 .execute_unprepared("INSERT INTO parents (id) VALUES (1)")
456 .await
457 .unwrap();
458
459 let orphan = db
462 .conn()
463 .execute_unprepared("INSERT INTO children (id, parent_id) VALUES (10, 999)")
464 .await
465 .expect_err("orphan insert must violate the FK");
466 assert!(
467 matches!(
468 orphan.sql_err(),
469 Some(sea_orm::SqlErr::ForeignKeyConstraintViolation(_))
470 ),
471 "must be an FK violation, got: {orphan}"
472 );
473
474 db.conn()
476 .execute_unprepared("INSERT INTO children (id, parent_id) VALUES (11, 1)")
477 .await
478 .unwrap();
479 db.conn()
480 .execute_unprepared("DELETE FROM parents WHERE id = 1")
481 .await
482 .unwrap();
483 let row = db
484 .conn()
485 .query_one(Statement::from_string(
486 sea_orm::DatabaseBackend::Sqlite,
487 "SELECT COUNT(*) AS n FROM children",
488 ))
489 .await
490 .unwrap()
491 .unwrap();
492 let n: i64 = row
493 .try_get::<i64>("", "n")
494 .or_else(|_| row.try_get::<i32>("", "n").map(i64::from))
495 .unwrap();
496 assert_eq!(n, 0, "ON DELETE CASCADE must remove the child rows");
497 }
498
499 fn demo_migrations() -> Vec<Migration> {
500 vec![
501 Migration {
502 name: "0001_create_todos",
503 sqlite: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)",
504 postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)",
505 },
506 Migration {
507 name: "0002_add_done",
508 sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0",
509 postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE",
510 },
511 ]
512 }
513
514 #[tokio::test]
515 async fn migrations_apply_in_order_and_only_once() {
516 let db = Db::connect("sqlite::memory:").await.unwrap();
517 let applied = db.migrate(&demo_migrations()).await.unwrap();
518 assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
519
520 let applied = db.migrate(&demo_migrations()).await.unwrap();
522 assert!(applied.is_empty());
523
524 db.conn()
526 .execute_unprepared("INSERT INTO todos (title, done) VALUES ('x', 1)")
527 .await
528 .unwrap();
529 }
530
531 #[tokio::test]
532 async fn owned_migrations_apply_in_order_and_only_once() {
533 let db = Db::connect("sqlite::memory:").await.unwrap();
534 let owned = vec![
535 OwnedMigration {
536 name: "0001_create_todos".into(),
537 sqlite:
538 "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)"
539 .into(),
540 postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)"
541 .into(),
542 },
543 OwnedMigration {
544 name: "0002_add_done".into(),
545 sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0".into(),
546 postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE".into(),
547 },
548 ];
549 let applied = db.migrate_owned(&owned).await.unwrap();
550 assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
551 let applied = db.migrate_owned(&owned).await.unwrap();
553 assert!(applied.is_empty());
554 }
555
556 #[tokio::test]
561 async fn transactions_roll_back_on_error() {
562 use sea_orm::TransactionTrait;
563 let db = Db::connect("sqlite::memory:").await.unwrap();
564 db.conn()
565 .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
566 .await
567 .unwrap();
568 let r = db
569 .conn()
570 .transaction::<_, (), sea_orm::DbErr>(|txn| {
571 Box::pin(async move {
572 txn.execute_unprepared("INSERT INTO t VALUES (1)").await?;
573 Err(sea_orm::DbErr::Custom("boom".into()))
574 })
575 })
576 .await;
577 assert!(r.is_err());
578 let rows = db
579 .conn()
580 .query_all(sea_orm::Statement::from_string(
581 sea_orm::DatabaseBackend::Sqlite,
582 "SELECT id FROM t",
583 ))
584 .await
585 .unwrap();
586 assert!(rows.is_empty(), "rollback must leave no rows");
587 }
588
589 #[tokio::test]
590 async fn a_failing_migration_surfaces_jc0510_and_is_not_recorded() {
591 let db = Db::connect("sqlite::memory:").await.unwrap();
592 let bad = vec![Migration {
593 name: "0001_broken",
594 sqlite: "CREATE GARBAGE",
595 postgres: "CREATE GARBAGE",
596 }];
597 let err = db.migrate(&bad).await.unwrap_err();
598 assert_eq!(err.code(), "JC0510");
599
600 let good = vec![Migration {
602 name: "0001_broken",
603 sqlite: "CREATE TABLE ok (x BIGINT)",
604 postgres: "CREATE TABLE ok (x BIGINT)",
605 }];
606 let applied = db.migrate(&good).await.unwrap();
607 assert_eq!(applied, vec!["0001_broken"]);
608 }
609
610 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
617 #[ignore = "needs a local postgres (set JERRYCAN_TEST_PG_URL)"]
618 async fn concurrent_migrators_do_not_race() {
619 let Ok(url) = std::env::var("JERRYCAN_TEST_PG_URL") else {
620 eprintln!("SKIP: JERRYCAN_TEST_PG_URL not set");
621 return;
622 };
623 let nanos = std::time::SystemTime::now()
626 .duration_since(std::time::UNIX_EPOCH)
627 .unwrap()
628 .as_nanos();
629 let table = format!("mig_race_{nanos}");
630 let name = format!("{table}_0001");
631 let migrations = vec![Migration {
632 name: Box::leak(name.clone().into_boxed_str()),
633 sqlite: "",
634 postgres: Box::leak(
635 format!("CREATE TABLE {table} (id BIGSERIAL PRIMARY KEY, v TEXT NOT NULL)")
636 .into_boxed_str(),
637 ),
638 }];
639 let migrations = std::sync::Arc::new(migrations);
640
641 let mut handles = Vec::new();
643 for _ in 0..8 {
644 let url = url.clone();
645 let migrations = migrations.clone();
646 handles.push(tokio::spawn(async move {
647 let db = Db::connect(&url).await.expect("connect");
648 db.migrate(&migrations).await
649 }));
650 }
651
652 let mut total_applied = 0usize;
653 for h in handles {
654 let applied = h.await.expect("task").expect("migrate must not error");
655 total_applied += applied.len();
656 }
657 assert_eq!(
658 total_applied, 1,
659 "exactly one migrator applies the migration; the rest see it recorded"
660 );
661
662 let db = Db::connect(&url).await.unwrap();
664 db.conn()
665 .execute_unprepared(&format!("INSERT INTO {table} (v) VALUES ('ok')"))
666 .await
667 .unwrap();
668 db.conn()
669 .execute_unprepared(&format!("DROP TABLE {table}"))
670 .await
671 .unwrap();
672 }
673}