use chrono::{DateTime, Utc};
use sqlx::Row;
use sqlx::types::Json;
use super::channel::NotificationError;
use super::dialect::{
NotificationDb, NotificationPool, StoredTime, restored_time, sql, stored_time,
};
use super::migrate;
use super::notification::DatabaseContent;
use super::stored::{ID_BYTES, NotificationId, StoredNotification};
type NotificationRow = <NotificationDb as sqlx::Database>::Row;
const STORE_ATTEMPTS: u32 = 8;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct DatabaseNotifications {
pool: NotificationPool,
}
impl DatabaseNotifications {
#[must_use]
pub fn new(pool: NotificationPool) -> Self {
Self { pool }
}
#[must_use]
pub fn pool(&self) -> &NotificationPool {
&self.pool
}
pub async fn migrate(&self) -> Result<(), NotificationError> {
migrate::apply(&self.pool).await
}
pub async fn store(
&self,
notifiable_key: &str,
content: &DatabaseContent,
) -> Result<StoredNotification, NotificationError> {
let created_at = Utc::now();
for _ in 0..STORE_ATTEMPTS {
let mut id_bytes = [0u8; ID_BYTES];
fill_random(&mut id_bytes)?;
let written = sqlx::query(sql::INSERT_NEW)
.bind(id_bytes.to_vec())
.bind(notifiable_key)
.bind(content.kind())
.bind(Json(content.data()))
.bind(stored_time(created_at))
.execute(&self.pool)
.await?
.rows_affected();
if written == 0 {
continue;
}
return Ok(StoredNotification {
id: NotificationId::from_bytes(id_bytes),
notifiable_key: notifiable_key.to_owned(),
kind: content.kind().to_owned(),
data: content.data().clone(),
read_at: None,
created_at,
});
}
Err(NotificationError::IdCollision {
attempts: STORE_ATTEMPTS,
})
}
pub async fn inbox(
&self,
notifiable_key: &str,
limit: u32,
) -> Result<Vec<StoredNotification>, NotificationError> {
self.list(sql::LIST, notifiable_key, limit).await
}
pub async fn unread(
&self,
notifiable_key: &str,
limit: u32,
) -> Result<Vec<StoredNotification>, NotificationError> {
self.list(sql::LIST_UNREAD, notifiable_key, limit).await
}
async fn list(
&self,
statement: &'static str,
notifiable_key: &str,
limit: u32,
) -> Result<Vec<StoredNotification>, NotificationError> {
let rows = sqlx::query(statement)
.bind(notifiable_key)
.bind(i64::from(limit))
.fetch_all(&self.pool)
.await?;
rows.iter().map(|row| decode(row, notifiable_key)).collect()
}
pub async fn unread_count(&self, notifiable_key: &str) -> Result<u64, NotificationError> {
let count: i64 = sqlx::query(sql::COUNT_UNREAD)
.bind(notifiable_key)
.fetch_one(&self.pool)
.await?
.try_get::<i64, _>(0)?;
Ok(count.max(0).unsigned_abs())
}
pub async fn mark_read(
&self,
notifiable_key: &str,
id: NotificationId,
) -> Result<bool, NotificationError> {
let affected = sqlx::query(sql::MARK_READ)
.bind(stored_time(Utc::now()))
.bind(notifiable_key)
.bind(id.as_bytes().to_vec())
.execute(&self.pool)
.await?
.rows_affected();
Ok(affected > 0)
}
pub async fn mark_all_read(&self, notifiable_key: &str) -> Result<u64, NotificationError> {
let result = sqlx::query(sql::MARK_ALL_READ)
.bind(stored_time(Utc::now()))
.bind(notifiable_key)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete(
&self,
notifiable_key: &str,
id: NotificationId,
) -> Result<bool, NotificationError> {
let affected = sqlx::query(sql::DELETE)
.bind(notifiable_key)
.bind(id.as_bytes().to_vec())
.execute(&self.pool)
.await?
.rows_affected();
Ok(affected > 0)
}
pub async fn delete_all_for(&self, notifiable_key: &str) -> Result<u64, NotificationError> {
let result = sqlx::query(sql::DELETE_ALL)
.bind(notifiable_key)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn prune_read_before(&self, cutoff: DateTime<Utc>) -> Result<u64, NotificationError> {
let result = sqlx::query(sql::DELETE_READ_BEFORE)
.bind(stored_time(cutoff))
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn decode(
row: &NotificationRow,
notifiable_key: &str,
) -> Result<StoredNotification, NotificationError> {
let id_bytes: Vec<u8> = row.try_get(0)?;
let id: [u8; ID_BYTES] = id_bytes.try_into().map_err(|bytes: Vec<u8>| {
NotificationError::Decode(format!(
"a notification id column held {} bytes, not {ID_BYTES}",
bytes.len()
))
})?;
let kind: String = row.try_get(1)?;
let data: Json<serde_json::Value> = row
.try_get(2)
.map_err(|error| NotificationError::Decode(error.to_string()))?;
let read_at: Option<StoredTime> = row.try_get(3)?;
let read_at = read_at.map(restored_time).transpose()?;
let created_at = restored_time(row.try_get(4)?)?;
Ok(StoredNotification {
id: NotificationId::from_bytes(id),
notifiable_key: notifiable_key.to_owned(),
kind,
data: data.0,
read_at,
created_at,
})
}
fn fill_random(buffer: &mut [u8]) -> Result<(), NotificationError> {
getrandom::fill(buffer).map_err(|_| NotificationError::Entropy)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_random_source_fills_the_whole_buffer() {
let mut first = [0u8; ID_BYTES];
let mut second = [0u8; ID_BYTES];
fill_random(&mut first).expect("the OS randomness source is available");
fill_random(&mut second).expect("the OS randomness source is available");
assert_ne!(first, [0u8; ID_BYTES]);
assert_ne!(first, second);
}
#[test]
fn every_per_row_statement_names_the_recipient() {
for (name, statement) in [
("INSERT_NEW", sql::INSERT_NEW),
("LIST", sql::LIST),
("LIST_UNREAD", sql::LIST_UNREAD),
("COUNT_UNREAD", sql::COUNT_UNREAD),
("MARK_READ", sql::MARK_READ),
("MARK_ALL_READ", sql::MARK_ALL_READ),
("DELETE", sql::DELETE),
("DELETE_ALL", sql::DELETE_ALL),
] {
assert!(
statement.contains("notifiable_key"),
"{name} is not scoped by its recipient: {statement}"
);
}
assert!(
!sql::DELETE_READ_BEFORE.contains("notifiable_key"),
"the retention sweep is now recipient-scoped; this test needs rewriting"
);
assert!(
sql::DELETE_READ_BEFORE.contains("read_at IS NOT NULL"),
"the retention sweep can reach unread notifications"
);
}
#[test]
fn marking_read_keeps_the_first_receipt() {
assert!(
sql::MARK_READ.contains("read_at IS NULL"),
"MARK_READ would overwrite an existing read time: {}",
sql::MARK_READ
);
}
#[test]
fn both_listings_agree_on_their_column_order() {
let columns = "SELECT id, kind, data, read_at, created_at";
assert!(sql::LIST.starts_with(columns), "{}", sql::LIST);
assert!(
sql::LIST_UNREAD.starts_with(columns),
"{}",
sql::LIST_UNREAD
);
}
}