use sqlx::{Executor, Row};
use super::channel::NotificationError;
use super::dialect::{NotificationDb, NotificationPool, sql};
type Conn = <NotificationDb as sqlx::Database>::Connection;
const STATEMENT_SEPARATOR: &str = "--;;";
const MIGRATIONS: &[(&str, &str)] = &[("0001_notifications", sql::SCHEMA)];
pub(super) async fn apply(pool: &NotificationPool) -> Result<(), NotificationError> {
let mut conn = pool.acquire().await?;
apply_on(&mut conn).await
}
async fn apply_on(conn: &mut Conn) -> Result<(), NotificationError> {
conn.execute(sql::CREATE_HISTORY).await?;
let Some(lock) = sql::LOCK else {
return apply_pending(conn).await;
};
sqlx::query(lock).execute(&mut *conn).await?;
let result = apply_pending(&mut *conn).await;
if let Some(unlock) = sql::UNLOCK {
let _ = sqlx::query(unlock).execute(&mut *conn).await;
}
result
}
async fn apply_pending(conn: &mut Conn) -> Result<(), NotificationError> {
for &(version, migration) in MIGRATIONS {
let applied: i64 = sqlx::query(sql::COUNT_APPLIED)
.bind(version)
.fetch_one(&mut *conn)
.await?
.try_get::<i64, _>(0)?;
if applied > 0 {
continue;
}
for statement in statements(migration) {
conn.execute(statement).await?;
}
sqlx::query(sql::RECORD_APPLIED)
.bind(version)
.execute(&mut *conn)
.await?;
}
Ok(())
}
fn statements(migration: &str) -> impl Iterator<Item = &str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut offset = 0;
for line in migration.split_inclusive('\n') {
if line.trim() == STATEMENT_SEPARATOR {
pieces.push(&migration[start..offset]);
start = offset + line.len();
}
offset += line.len();
}
pieces.push(&migration[start..]);
pieces
.into_iter()
.map(str::trim)
.filter(|statement| !statement.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bundled_migration_splits_into_at_least_one_statement() {
for &(version, migration) in MIGRATIONS {
let count = statements(migration).count();
assert!(count > 0, "{version} produced no statements");
}
}
#[test]
fn the_bundled_migration_creates_the_notifications_table() {
for &(version, migration) in MIGRATIONS {
assert!(
statements(migration).any(|statement| statement
.contains("CREATE TABLE IF NOT EXISTS arcature_notifications")),
"{version} does not create arcature_notifications"
);
}
}
#[test]
fn every_row_is_scoped_by_its_recipient() {
for &(version, migration) in MIGRATIONS {
let key_line = migration
.lines()
.find(|line| line.trim_start().starts_with("notifiable_key"))
.unwrap_or_else(|| panic!("{version} declares no notifiable_key column"));
assert!(
key_line.contains("NOT NULL"),
"{version} allows an unowned notification: {key_line}"
);
}
}
#[test]
fn unread_is_the_absence_of_a_timestamp() {
for &(version, migration) in MIGRATIONS {
let read_line = migration
.lines()
.find(|line| line.trim_start().starts_with("read_at"))
.unwrap_or_else(|| panic!("{version} declares no read_at column"));
assert!(
!read_line.contains("NOT NULL"),
"{version} cannot represent an unread notification: {read_line}"
);
}
}
#[test]
fn the_inbox_read_is_indexed() {
for &(version, migration) in MIGRATIONS {
assert!(
migration.contains("arcature_notifications_inbox_idx"),
"{version} does not index the inbox read"
);
assert!(
migration.contains("arcature_notifications_unread_idx"),
"{version} does not index the unread badge"
);
}
}
}