mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Add multi-database data layer
Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite. Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers. Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod mysql;
|
||||
mod postgres;
|
||||
mod sqlite;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryAnnouncementReadRepository;
|
||||
pub use sql::SqlxAnnouncementReadRepository;
|
||||
pub use mysql::MysqlAnnouncementRepository;
|
||||
pub use postgres::SqlxAnnouncementReadRepository;
|
||||
pub use sqlite::SqliteAnnouncementRepository;
|
||||
pub use types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
|
||||
322
crates/aether-data/src/repository/announcements/mysql.rs
Normal file
322
crates/aether-data/src/repository/announcements/mysql.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{mysql::MySqlRow, Row};
|
||||
|
||||
use super::types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
};
|
||||
use crate::driver::mysql::MysqlPool;
|
||||
use crate::error::SqlResultExt;
|
||||
use crate::DataLayerError;
|
||||
|
||||
const ANNOUNCEMENT_SELECT: &str = r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.content,
|
||||
a.`type` AS type,
|
||||
a.priority,
|
||||
a.is_active,
|
||||
a.is_pinned,
|
||||
a.author_id,
|
||||
u.username AS author_username,
|
||||
a.start_time AS start_time_unix_secs,
|
||||
a.end_time AS end_time_unix_secs,
|
||||
a.created_at AS created_at_unix_ms,
|
||||
a.updated_at AS updated_at_unix_secs
|
||||
FROM announcements a
|
||||
LEFT JOIN users u ON u.id = a.author_id
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MysqlAnnouncementRepository {
|
||||
pool: MysqlPool,
|
||||
}
|
||||
|
||||
impl MysqlAnnouncementRepository {
|
||||
pub fn new(pool: MysqlPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn reload_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
self.find_by_id(announcement_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementReadRepository for MysqlAnnouncementRepository {
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
let row = sqlx::query(&format!("{ANNOUNCEMENT_SELECT} WHERE a.id = ? LIMIT 1"))
|
||||
.bind(announcement_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
row.as_ref().map(map_announcement_row).transpose()
|
||||
}
|
||||
|
||||
async fn list_announcements(
|
||||
&self,
|
||||
query: &AnnouncementListQuery,
|
||||
) -> Result<StoredAnnouncementPage, DataLayerError> {
|
||||
let now_unix_secs = query.now_unix_secs.unwrap_or_else(current_unix_secs);
|
||||
let total_row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE (
|
||||
NOT ? OR (
|
||||
a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let total = total_row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64;
|
||||
|
||||
let rows = sqlx::query(&format!(
|
||||
r#"
|
||||
{ANNOUNCEMENT_SELECT}
|
||||
WHERE (
|
||||
NOT ? OR (
|
||||
a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
)
|
||||
)
|
||||
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"#
|
||||
))
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(query.limit as i64)
|
||||
.bind(query.offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(map_announcement_row)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM announcement_reads r
|
||||
WHERE r.user_id = ?
|
||||
AND r.announcement_id = a.id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementWriteRepository for MysqlAnnouncementRepository {
|
||||
async fn create_announcement(
|
||||
&self,
|
||||
record: CreateAnnouncementRecord,
|
||||
) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
record.validate()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = current_unix_secs() as i64;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO announcements (
|
||||
id, title, content, `type`, priority, author_id, is_active, is_pinned,
|
||||
start_time, end_time, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.author_id)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.start_time_unix_secs,
|
||||
"announcements.start_time",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.end_time_unix_secs,
|
||||
"announcements.end_time",
|
||||
)?)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
self.reload_by_id(&id)
|
||||
.await?
|
||||
.ok_or_else(|| DataLayerError::UnexpectedValue("created announcement missing".into()))
|
||||
}
|
||||
|
||||
async fn update_announcement(
|
||||
&self,
|
||||
record: UpdateAnnouncementRecord,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
record.validate()?;
|
||||
let id = record.announcement_id;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE announcements
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
`type` = COALESCE(?, `type`),
|
||||
priority = COALESCE(?, priority),
|
||||
is_active = COALESCE(?, is_active),
|
||||
is_pinned = COALESCE(?, is_pinned),
|
||||
start_time = COALESCE(?, start_time),
|
||||
end_time = COALESCE(?, end_time),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.is_active)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.start_time_unix_secs,
|
||||
"announcements.start_time",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.end_time_unix_secs,
|
||||
"announcements.end_time",
|
||||
)?)
|
||||
.bind(current_unix_secs() as i64)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
self.reload_by_id(&id).await
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
&self,
|
||||
user_id: &str,
|
||||
announcement_id: &str,
|
||||
read_at_unix_secs: u64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO announcement_reads (id, user_id, announcement_id, read_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user_id)
|
||||
.bind(announcement_id)
|
||||
.bind(i64_from_u64(
|
||||
read_at_unix_secs,
|
||||
"announcement_reads.read_at",
|
||||
)?)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
chrono::Utc::now().timestamp().max(0) as u64
|
||||
}
|
||||
|
||||
fn i64_from_u64(value: u64, field_name: &str) -> Result<i64, DataLayerError> {
|
||||
i64::try_from(value)
|
||||
.map_err(|_| DataLayerError::InvalidInput(format!("{field_name} exceeds i64: {value}")))
|
||||
}
|
||||
|
||||
fn optional_i64_from_u64(
|
||||
value: Option<u64>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<i64>, DataLayerError> {
|
||||
value
|
||||
.map(|value| i64_from_u64(value, field_name))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn map_announcement_row(row: &MySqlRow) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
StoredAnnouncement::new(
|
||||
row.try_get("id").map_sql_err()?,
|
||||
row.try_get("title").map_sql_err()?,
|
||||
row.try_get("content").map_sql_err()?,
|
||||
row.try_get("type").map_sql_err()?,
|
||||
row.try_get("priority").map_sql_err()?,
|
||||
row.try_get("is_active").map_sql_err()?,
|
||||
row.try_get("is_pinned").map_sql_err()?,
|
||||
row.try_get("author_id").map_sql_err()?,
|
||||
row.try_get("author_username").map_sql_err()?,
|
||||
row.try_get("start_time_unix_secs").map_sql_err()?,
|
||||
row.try_get("end_time_unix_secs").map_sql_err()?,
|
||||
row.try_get("created_at_unix_ms").map_sql_err()?,
|
||||
row.try_get("updated_at_unix_secs").map_sql_err()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MysqlAnnouncementRepository;
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_builds_from_lazy_pool() {
|
||||
let pool = sqlx::mysql::MySqlPoolOptions::new().connect_lazy_with(
|
||||
"mysql://user:pass@localhost:3306/aether"
|
||||
.parse()
|
||||
.expect("mysql options should parse"),
|
||||
);
|
||||
|
||||
let _repository = MysqlAnnouncementRepository::new(pool);
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ fn map_announcement_row(row: &PgRow) -> Result<StoredAnnouncement, DataLayerErro
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqlxAnnouncementReadRepository;
|
||||
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
421
crates/aether-data/src/repository/announcements/sqlite.rs
Normal file
421
crates/aether-data/src/repository/announcements/sqlite.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{sqlite::SqliteRow, Row};
|
||||
|
||||
use super::types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
};
|
||||
use crate::driver::sqlite::SqlitePool;
|
||||
use crate::error::SqlResultExt;
|
||||
use crate::DataLayerError;
|
||||
|
||||
const ANNOUNCEMENT_SELECT: &str = r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.content,
|
||||
a.type,
|
||||
a.priority,
|
||||
a.is_active,
|
||||
a.is_pinned,
|
||||
a.author_id,
|
||||
u.username AS author_username,
|
||||
a.start_time AS start_time_unix_secs,
|
||||
a.end_time AS end_time_unix_secs,
|
||||
a.created_at AS created_at_unix_ms,
|
||||
a.updated_at AS updated_at_unix_secs
|
||||
FROM announcements a
|
||||
LEFT JOIN users u ON u.id = a.author_id
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqliteAnnouncementRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteAnnouncementRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn reload_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
self.find_by_id(announcement_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementReadRepository for SqliteAnnouncementRepository {
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
let row = sqlx::query(&format!("{ANNOUNCEMENT_SELECT} WHERE a.id = ? LIMIT 1"))
|
||||
.bind(announcement_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
row.as_ref().map(map_announcement_row).transpose()
|
||||
}
|
||||
|
||||
async fn list_announcements(
|
||||
&self,
|
||||
query: &AnnouncementListQuery,
|
||||
) -> Result<StoredAnnouncementPage, DataLayerError> {
|
||||
let now_unix_secs = query.now_unix_secs.unwrap_or_else(current_unix_secs);
|
||||
let total_row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE (
|
||||
NOT ? OR (
|
||||
a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let total = total_row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64;
|
||||
|
||||
let rows = sqlx::query(&format!(
|
||||
r#"
|
||||
{ANNOUNCEMENT_SELECT}
|
||||
WHERE (
|
||||
NOT ? OR (
|
||||
a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
)
|
||||
)
|
||||
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"#
|
||||
))
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(query.limit as i64)
|
||||
.bind(query.offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(map_announcement_row)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE a.is_active = 1
|
||||
AND (a.start_time IS NULL OR a.start_time <= ?)
|
||||
AND (a.end_time IS NULL OR a.end_time >= ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM announcement_reads r
|
||||
WHERE r.user_id = ?
|
||||
AND r.announcement_id = a.id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(now_unix_secs as i64)
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementWriteRepository for SqliteAnnouncementRepository {
|
||||
async fn create_announcement(
|
||||
&self,
|
||||
record: CreateAnnouncementRecord,
|
||||
) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
record.validate()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = current_unix_secs() as i64;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO announcements (
|
||||
id, title, content, type, priority, author_id, is_active, is_pinned,
|
||||
start_time, end_time, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.author_id)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.start_time_unix_secs,
|
||||
"announcements.start_time",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.end_time_unix_secs,
|
||||
"announcements.end_time",
|
||||
)?)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
self.reload_by_id(&id)
|
||||
.await?
|
||||
.ok_or_else(|| DataLayerError::UnexpectedValue("created announcement missing".into()))
|
||||
}
|
||||
|
||||
async fn update_announcement(
|
||||
&self,
|
||||
record: UpdateAnnouncementRecord,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
record.validate()?;
|
||||
let id = record.announcement_id;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE announcements
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
type = COALESCE(?, type),
|
||||
priority = COALESCE(?, priority),
|
||||
is_active = COALESCE(?, is_active),
|
||||
is_pinned = COALESCE(?, is_pinned),
|
||||
start_time = COALESCE(?, start_time),
|
||||
end_time = COALESCE(?, end_time),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.is_active)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.start_time_unix_secs,
|
||||
"announcements.start_time",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
record.end_time_unix_secs,
|
||||
"announcements.end_time",
|
||||
)?)
|
||||
.bind(current_unix_secs() as i64)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
self.reload_by_id(&id).await
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
&self,
|
||||
user_id: &str,
|
||||
announcement_id: &str,
|
||||
read_at_unix_secs: u64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query(
|
||||
r#"
|
||||
INSERT OR IGNORE INTO announcement_reads (id, user_id, announcement_id, read_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user_id)
|
||||
.bind(announcement_id)
|
||||
.bind(i64_from_u64(
|
||||
read_at_unix_secs,
|
||||
"announcement_reads.read_at",
|
||||
)?)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
chrono::Utc::now().timestamp().max(0) as u64
|
||||
}
|
||||
|
||||
fn i64_from_u64(value: u64, field_name: &str) -> Result<i64, DataLayerError> {
|
||||
i64::try_from(value)
|
||||
.map_err(|_| DataLayerError::InvalidInput(format!("{field_name} exceeds i64: {value}")))
|
||||
}
|
||||
|
||||
fn optional_i64_from_u64(
|
||||
value: Option<u64>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<i64>, DataLayerError> {
|
||||
value
|
||||
.map(|value| i64_from_u64(value, field_name))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn map_announcement_row(row: &SqliteRow) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
StoredAnnouncement::new(
|
||||
row.try_get("id").map_sql_err()?,
|
||||
row.try_get("title").map_sql_err()?,
|
||||
row.try_get("content").map_sql_err()?,
|
||||
row.try_get("type").map_sql_err()?,
|
||||
row.try_get("priority").map_sql_err()?,
|
||||
row.try_get("is_active").map_sql_err()?,
|
||||
row.try_get("is_pinned").map_sql_err()?,
|
||||
row.try_get("author_id").map_sql_err()?,
|
||||
row.try_get("author_username").map_sql_err()?,
|
||||
row.try_get("start_time_unix_secs").map_sql_err()?,
|
||||
row.try_get("end_time_unix_secs").map_sql_err()?,
|
||||
row.try_get("created_at_unix_ms").map_sql_err()?,
|
||||
row.try_get("updated_at_unix_secs").map_sql_err()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqliteAnnouncementRepository;
|
||||
use crate::lifecycle::migrate::run_sqlite_migrations;
|
||||
use crate::repository::announcements::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, UpdateAnnouncementRecord,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_repository_reads_and_writes_announcements() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
run_sqlite_migrations(&pool)
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
seed_announcement_user(&pool).await;
|
||||
|
||||
let repository = SqliteAnnouncementRepository::new(pool);
|
||||
let created = repository
|
||||
.create_announcement(CreateAnnouncementRecord {
|
||||
title: "Initial".to_string(),
|
||||
content: "Body".to_string(),
|
||||
kind: "info".to_string(),
|
||||
priority: 10,
|
||||
is_pinned: true,
|
||||
author_id: "user-1".to_string(),
|
||||
start_time_unix_secs: Some(100),
|
||||
end_time_unix_secs: Some(300),
|
||||
})
|
||||
.await
|
||||
.expect("announcement should create");
|
||||
assert_eq!(created.author_username, Some("admin".to_string()));
|
||||
assert!(created.is_active);
|
||||
|
||||
let page = repository
|
||||
.list_announcements(&AnnouncementListQuery {
|
||||
active_only: true,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
now_unix_secs: Some(200),
|
||||
})
|
||||
.await
|
||||
.expect("announcements should list");
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].id, created.id);
|
||||
|
||||
let unread = repository
|
||||
.count_unread_active_announcements("user-1", 200)
|
||||
.await
|
||||
.expect("unread count should load");
|
||||
assert_eq!(unread, 1);
|
||||
assert!(repository
|
||||
.mark_announcement_as_read("user-1", &created.id, 210)
|
||||
.await
|
||||
.expect("read marker should insert"));
|
||||
assert!(!repository
|
||||
.mark_announcement_as_read("user-1", &created.id, 211)
|
||||
.await
|
||||
.expect("duplicate read marker should be ignored"));
|
||||
assert_eq!(
|
||||
repository
|
||||
.count_unread_active_announcements("user-1", 200)
|
||||
.await
|
||||
.expect("unread count should reload"),
|
||||
0
|
||||
);
|
||||
|
||||
let updated = repository
|
||||
.update_announcement(UpdateAnnouncementRecord {
|
||||
announcement_id: created.id.clone(),
|
||||
title: Some("Updated".to_string()),
|
||||
content: None,
|
||||
kind: None,
|
||||
priority: Some(20),
|
||||
is_active: Some(false),
|
||||
is_pinned: Some(false),
|
||||
start_time_unix_secs: None,
|
||||
end_time_unix_secs: None,
|
||||
})
|
||||
.await
|
||||
.expect("announcement should update")
|
||||
.expect("announcement should exist");
|
||||
assert_eq!(updated.title, "Updated");
|
||||
assert!(!updated.is_active);
|
||||
|
||||
assert!(repository
|
||||
.delete_announcement(&created.id)
|
||||
.await
|
||||
.expect("announcement should delete"));
|
||||
assert!(repository
|
||||
.find_by_id(&created.id)
|
||||
.await
|
||||
.expect("find should run")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
async fn seed_announcement_user(pool: &sqlx::SqlitePool) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (
|
||||
id, email, username, role, auth_source, email_verified, is_active, is_deleted, created_at, updated_at
|
||||
)
|
||||
VALUES ('user-1', 'admin@example.com', 'admin', 'admin', 'local', 1, 1, 0, 1, 1)
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("user should seed");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user