refactor: sqlite repositories per aggregate
This commit is contained in:
110
crates/adapters/sqlite/src/repositories/ap_content/mod.rs
Normal file
110
crates/adapters/sqlite/src/repositories/ap_content/mod.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, WatchlistWithMovie},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteApContentQuery {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteApContentQuery {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
mod rows;
|
||||
use rows::{DiaryRow, WatchlistRow};
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows: Vec<WatchlistRow> = sqlx::query_as(
|
||||
"SELECT w.id, w.user_id, w.movie_id, w.added_at,
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year,
|
||||
m.director, m.poster_path
|
||||
FROM watchlist_entries w
|
||||
JOIN movies m ON m.id = w.movie_id
|
||||
WHERE w.user_id = ?
|
||||
ORDER BY w.added_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
rows.into_iter().map(WatchlistRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&mid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows = if let Some(before_ts) = before {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&ts)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
}
|
||||
172
crates/adapters/sqlite/src/repositories/ap_content/rows.rs
Normal file
172
crates/adapters/sqlite/src/repositories/ap_content/rows.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::shared::{parse_datetime, parse_uuid};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct MovieRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
pub(super) fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReviewRow {
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
let source = match self.remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct DiaryRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub(super) fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct WatchlistRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
added_at: String,
|
||||
m_id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
pub(super) fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
|
||||
added_at: parse_datetime(&self.added_at)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: self.m_id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
}
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
174
crates/adapters/sqlite/src/repositories/diary/mod.rs
Normal file
174
crates/adapters/sqlite/src/repositories/diary/mod.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use domain::{errors::DomainError, models::ReviewSortBy};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use rows::DiaryRow;
|
||||
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub struct SqliteDiaryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteDiaryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
||||
match movie_id {
|
||||
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error),
|
||||
Some(id) => {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_all_diary_rows(
|
||||
&self,
|
||||
sort: &ReviewSortBy,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let order_clause = match sort {
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
LIMIT ? OFFSET ?",
|
||||
order_clause
|
||||
);
|
||||
sqlx::query_as::<_, DiaryRow>(&sql)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_movie_diary_rows(
|
||||
&self,
|
||||
movie_id: &str,
|
||||
sort: &ReviewSortBy,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let order_clause = match sort {
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ?
|
||||
ORDER BY {}
|
||||
LIMIT ? OFFSET ?",
|
||||
order_clause
|
||||
);
|
||||
sqlx::query_as::<_, DiaryRow>(&sql)
|
||||
.bind(movie_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn count_user_diary_entries(
|
||||
&self,
|
||||
user_id: &str,
|
||||
search: Option<&str>,
|
||||
include_remote: bool,
|
||||
) -> Result<i64, DomainError> {
|
||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let remote_clause = if include_remote {
|
||||
""
|
||||
} else {
|
||||
" AND r.remote_actor_url IS NULL"
|
||||
};
|
||||
let search_clause = if has_search {
|
||||
" AND m.title LIKE '%' || ? || '%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?{remote_clause}{search_clause}"
|
||||
);
|
||||
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
||||
if has_search {
|
||||
q = q.bind(search.unwrap());
|
||||
}
|
||||
q.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_user_diary_rows(
|
||||
&self,
|
||||
user_id: &str,
|
||||
sort: &ReviewSortBy,
|
||||
search: Option<&str>,
|
||||
include_remote: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let remote_clause = if include_remote {
|
||||
""
|
||||
} else {
|
||||
" AND r.remote_actor_url IS NULL"
|
||||
};
|
||||
let search_clause = if has_search {
|
||||
" AND m.title LIKE '%' || ? || '%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let order_clause = match sort {
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?{remote_clause}{search_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ? OFFSET ?",
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
||||
if has_search {
|
||||
q = q.bind(search.unwrap());
|
||||
}
|
||||
q.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
356
crates/adapters/sqlite/src/repositories/diary/query.rs
Normal file
356
crates/adapters/sqlite/src/repositories/diary/query.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::DiaryQuery,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use super::SqliteDiaryRepository;
|
||||
use super::rows::{DiaryRow, FeedRow, MovieStatsRow};
|
||||
use crate::repositories::movie::rows::MovieRow;
|
||||
use crate::repositories::review::rows::ReviewRow;
|
||||
|
||||
#[async_trait]
|
||||
impl DiaryQuery for SqliteDiaryRepository {
|
||||
async fn query_diary(
|
||||
&self,
|
||||
filter: &DiaryFilter,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
let limit = filter.page.limit as i64;
|
||||
let offset = filter.page.offset as i64;
|
||||
|
||||
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
|
||||
(None, None) => tokio::try_join!(
|
||||
self.count_diary_entries(None),
|
||||
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
|
||||
)?,
|
||||
(Some(id), None) => {
|
||||
let id_str = id.value().to_string();
|
||||
tokio::try_join!(
|
||||
self.count_diary_entries(Some(id_str.as_str())),
|
||||
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
|
||||
)?
|
||||
}
|
||||
(None, Some(uid)) => {
|
||||
let uid_str = uid.value().to_string();
|
||||
let search = filter.search.as_deref();
|
||||
let inc = filter.include_remote;
|
||||
tokio::try_join!(
|
||||
self.count_user_diary_entries(&uid_str, search, inc),
|
||||
self.fetch_user_diary_rows(
|
||||
&uid_str,
|
||||
&filter.sort_by,
|
||||
search,
|
||||
inc,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
)?
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Combined movie_id + user_id filter not supported".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(DiaryRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: filter.page.limit,
|
||||
offset: filter.page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_activity_feed(
|
||||
&self,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn query_activity_feed_filtered(
|
||||
&self,
|
||||
page: &PageParams,
|
||||
sort_by: &domain::models::FeedSortBy,
|
||||
search: Option<&str>,
|
||||
following: Option<&domain::models::FollowingFilter>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
use domain::models::FeedSortBy;
|
||||
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
let mut where_parts = vec!["1=1".to_string()];
|
||||
|
||||
if has_search {
|
||||
where_parts.push("m.title LIKE '%' || ? || '%'".to_string());
|
||||
}
|
||||
|
||||
if let Some(f) = following {
|
||||
let local_in = if f.local_user_ids.is_empty() {
|
||||
"SELECT NULL WHERE 0".to_string()
|
||||
} else {
|
||||
f.local_user_ids
|
||||
.iter()
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
let remote_in = if f.remote_actor_urls.is_empty() {
|
||||
"SELECT NULL WHERE 0".to_string()
|
||||
} else {
|
||||
f.remote_actor_urls
|
||||
.iter()
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
where_parts.push(format!(
|
||||
"(r.user_id IN ({}) OR r.remote_actor_url IN ({}))",
|
||||
local_in, remote_in
|
||||
));
|
||||
}
|
||||
|
||||
let order_clause = match sort_by {
|
||||
FeedSortBy::Date => "r.watched_at DESC",
|
||||
FeedSortBy::DateAsc => "r.watched_at ASC",
|
||||
FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC",
|
||||
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
};
|
||||
|
||||
let where_clause = where_parts.join(" AND ");
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE {}",
|
||||
where_clause
|
||||
);
|
||||
|
||||
let select_sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||
WHERE {}
|
||||
ORDER BY {}
|
||||
LIMIT ? OFFSET ?",
|
||||
where_clause, order_clause
|
||||
);
|
||||
|
||||
macro_rules! bind_filter_params {
|
||||
($q:expr) => {{
|
||||
let mut q = $q;
|
||||
if has_search {
|
||||
q = q.bind(search.unwrap());
|
||||
}
|
||||
if let Some(f) = following {
|
||||
for uid in &f.local_user_ids {
|
||||
q = q.bind(uid.to_string());
|
||||
}
|
||||
for url in &f.remote_actor_urls {
|
||||
q = q.bind(url.as_str());
|
||||
}
|
||||
}
|
||||
q
|
||||
}};
|
||||
}
|
||||
|
||||
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
|
||||
let total = count_q
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
|
||||
let rows = rows_q
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
let id_str = movie_id.value().to_string();
|
||||
|
||||
let movie = sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ReviewHistory::new(movie, viewings))
|
||||
}
|
||||
|
||||
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
ORDER BY r.watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
fn stream_user_history(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
) -> BoxStream<'static, Result<DiaryEntry, DomainError>> {
|
||||
let pool = self.pool.clone();
|
||||
let uid = user_id.value().to_string();
|
||||
Box::pin(async_stream::stream! {
|
||||
let mut rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
ORDER BY r.watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch(&pool);
|
||||
while let Some(row) = futures::StreamExt::next(&mut rows).await {
|
||||
yield match row {
|
||||
Ok(r) => r.into_domain(),
|
||||
Err(e) => Err(crate::shared::map_sqlx_error(e)),
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||
let id_str = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieStatsRow>(
|
||||
"SELECT
|
||||
COUNT(*) AS total_count,
|
||||
AVG(CAST(rating AS REAL)) AS avg_rating,
|
||||
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
|
||||
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
|
||||
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
|
||||
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
|
||||
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
|
||||
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
|
||||
FROM reviews WHERE movie_id = ?",
|
||||
)
|
||||
.bind(id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
.map(MovieStatsRow::into_domain)
|
||||
}
|
||||
|
||||
async fn get_movie_social_feed(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let id_str = movie_id.value().to_string();
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let total: i64 =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||
WHERE r.movie_id = ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
128
crates/adapters/sqlite/src/repositories/diary/rows.rs
Normal file
128
crates/adapters/sqlite/src/repositories/diary/rows.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use crate::shared::{movie_row_to_domain, movie_stats_to_domain, review_row_to_domain};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, FeedEntry},
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct DiaryRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub review_id: String,
|
||||
pub movie_id: String,
|
||||
pub user_id: String,
|
||||
pub rating: i64,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
let review = review_row_to_domain(
|
||||
self.review_id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct FeedRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub review_id: String,
|
||||
pub movie_id: String,
|
||||
pub user_id: String,
|
||||
pub rating: i64,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
impl FeedRow {
|
||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
let review = review_row_to_domain(
|
||||
self.review_id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieStatsRow {
|
||||
pub total_count: i64,
|
||||
pub avg_rating: Option<f64>,
|
||||
pub federated_count: i64,
|
||||
pub rating_1: i64,
|
||||
pub rating_2: i64,
|
||||
pub rating_3: i64,
|
||||
pub rating_4: i64,
|
||||
pub rating_5: i64,
|
||||
}
|
||||
|
||||
impl MovieStatsRow {
|
||||
pub fn into_domain(self) -> domain::models::MovieStats {
|
||||
movie_stats_to_domain(
|
||||
self.total_count,
|
||||
self.avg_rating,
|
||||
self.federated_count,
|
||||
[
|
||||
self.rating_1,
|
||||
self.rating_2,
|
||||
self.rating_3,
|
||||
self.rating_4,
|
||||
self.rating_5,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
207
crates/adapters/sqlite/src/repositories/diary/tests.rs
Normal file
207
crates/adapters/sqlite/src/repositories/diary/tests.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::collections::PageParams,
|
||||
models::{FeedSortBy, FollowingFilter},
|
||||
ports::DiaryQuery,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn setup(pool: &SqlitePool) {
|
||||
sqlx::migrate!("./migrations").run(pool).await.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'alice@example.com', 'alice', 'hash', '2024-01-01 00:00:00'),
|
||||
('22222222-2222-2222-2222-222222222222', 'bob@example.com', 'bob', 'hash', '2024-01-01 00:00:00'),
|
||||
('33333333-3333-3333-3333-333333333333', 'carol@remote.social', 'carol', 'hash', '2024-01-01 00:00:00')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, title, release_year) VALUES
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Inception', 2010),
|
||||
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Interstellar', 2014),
|
||||
('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Dune', 2021)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES
|
||||
('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 5, '2024-01-01 00:00:00', '2024-01-01 00:00:00', NULL),
|
||||
('b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', 3, '2024-01-02 00:00:00', '2024-01-02 00:00:00', NULL),
|
||||
('c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', 4, '2024-01-03 00:00:00', '2024-01-03 00:00:00', 'https://remote.social/users/carol')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sort_by_rating_descending() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
let result = repo
|
||||
.query_activity_feed_filtered(&page, &FeedSortBy::Rating, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ratings: Vec<u8> = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|e| e.review().rating().value())
|
||||
.collect();
|
||||
assert_eq!(ratings, vec![5, 4, 3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_by_title() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
let result = repo
|
||||
.query_activity_feed_filtered(&page, &FeedSortBy::Date, Some("Dune"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.items[0].movie().title().value(), "Dune");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_following_filter() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let filter = FollowingFilter {
|
||||
local_user_ids: vec![
|
||||
uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
|
||||
],
|
||||
remote_actor_urls: vec!["https://remote.social/users/carol".to_string()],
|
||||
};
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
let result = repo
|
||||
.query_activity_feed_filtered(&page, &FeedSortBy::Date, None, Some(&filter))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2);
|
||||
let titles: Vec<String> = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|e| e.movie().title().value().to_string())
|
||||
.collect();
|
||||
assert!(titles.contains(&"Inception".to_string()));
|
||||
assert!(titles.contains(&"Dune".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_movie_stats_local() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||
);
|
||||
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
|
||||
|
||||
assert_eq!(stats.total_count, 1);
|
||||
assert_eq!(stats.federated_count, 0);
|
||||
assert!((stats.avg_rating.unwrap() - 5.0).abs() < 0.001);
|
||||
assert_eq!(stats.rating_histogram[4], 1);
|
||||
assert_eq!(stats.rating_histogram[0], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_movie_social_feed_returns_reviews_for_movie() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||
);
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
|
||||
|
||||
assert_eq!(result.total_count, 1);
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.items[0].movie().title().value(), "Inception");
|
||||
assert_eq!(result.items[0].review().rating().value(), 5);
|
||||
assert_eq!(result.items[0].user_display_name(), "alice");
|
||||
assert!(!result.items[0].review().is_remote());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_movie_social_feed_federated_review() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
|
||||
);
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
|
||||
|
||||
assert_eq!(result.total_count, 1);
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert!(result.items[0].review().is_remote());
|
||||
assert_eq!(
|
||||
result.items[0].user_email(),
|
||||
"https://remote.social/users/carol"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_movie_social_feed_pagination() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||
);
|
||||
let page = PageParams::new(Some(10), Some(5)).unwrap();
|
||||
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
|
||||
|
||||
assert_eq!(result.total_count, 1);
|
||||
assert_eq!(result.items.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_movie_stats_federated() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
|
||||
);
|
||||
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
|
||||
|
||||
assert_eq!(stats.total_count, 1);
|
||||
assert_eq!(stats.federated_count, 1);
|
||||
assert_eq!(stats.rating_histogram[3], 1);
|
||||
assert_eq!(stats.rating_histogram[4], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_local_posts_excludes_remote_reviews() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
let count = repo.count_local_posts().await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
190
crates/adapters/sqlite/src/repositories/goals/mod.rs
Normal file
190
crates/adapters/sqlite/src/repositories/goals/mod.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{Goal, GoalType},
|
||||
ports::{GoalCommand, GoalQuery},
|
||||
value_objects::{GoalId, UserId},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteGoalRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteGoalRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalCommand for SqliteGoalRepository {
|
||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let id = goal.id().value().to_string();
|
||||
let user_id = goal.user_id().value().to_string();
|
||||
let year = goal.year() as i64;
|
||||
let target = goal.target_count() as i64;
|
||||
let goal_type = goal.goal_type().as_str();
|
||||
let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(year)
|
||||
.bind(target)
|
||||
.bind(goal_type)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let id = goal.id().value().to_string();
|
||||
let target = goal.target_count() as i64;
|
||||
|
||||
let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?")
|
||||
.bind(target)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("Goal not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let gid = id.value().to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?")
|
||||
.bind(&gid)
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("Goal not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalQuery for SqliteGoalRepository {
|
||||
async fn find_by_user_and_year(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? AND year = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| row_to_goal(&r)).transpose()
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? ORDER BY year DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
pool: &SqlitePool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
pub(crate) fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(
|
||||
uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
|
||||
);
|
||||
let user_id = UserId::from_uuid(
|
||||
uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
|
||||
);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime: {e}")))?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
69
crates/adapters/sqlite/src/repositories/image_ref/mod.rs
Normal file
69
crates/adapters/sqlite/src/repositories/image_ref/mod.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageRefCommand, ImageRefQuery},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct SqliteImageRefAdapter {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteImageRefAdapter {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
|
||||
let adapter = Arc::new(SqliteImageRefAdapter::new(pool));
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
|
||||
adapter as Arc<dyn ImageRefQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefCommand for SqliteImageRefAdapter {
|
||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?")
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?")
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefQuery for SqliteImageRefAdapter {
|
||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
||||
UNION
|
||||
SELECT poster_path FROM movies WHERE poster_path IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(|(k,)| k).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
118
crates/adapters/sqlite/src/repositories/image_ref/tests.rs
Normal file
118
crates/adapters/sqlite/src/repositories/image_ref/tests.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
|
||||
async fn setup(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'standard',
|
||||
bio TEXT,
|
||||
avatar_path TEXT
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS movies (
|
||||
id TEXT PRIMARY KEY,
|
||||
external_metadata_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
release_year INTEGER,
|
||||
director TEXT,
|
||||
poster_path TEXT
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_keys_returns_both_avatar_and_poster_paths() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
let mut keys = adapter.list_keys().await.unwrap();
|
||||
keys.sort();
|
||||
|
||||
assert_eq!(keys, vec!["avatars/u1", "posters/m1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_keys_excludes_nulls() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_updates_avatar_path() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool.clone());
|
||||
adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap();
|
||||
|
||||
let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_updates_poster_path() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool.clone());
|
||||
adapter.swap("posters/m1", "posters/m1.avif").await.unwrap();
|
||||
|
||||
let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_noop_when_key_not_found() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
adapter
|
||||
.swap("missing/key", "missing/key.avif")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
138
crates/adapters/sqlite/src/repositories/import_profile/mod.rs
Normal file
138
crates/adapters/sqlite/src/repositories/import_profile/mod.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ImportProfile,
|
||||
ports::ImportProfileRepository,
|
||||
value_objects::{ImportProfileId, UserId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod rows;
|
||||
use rows::*;
|
||||
|
||||
pub struct SqliteImportProfileRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteImportProfileRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImportProfileRepository for SqliteImportProfileRepository {
|
||||
async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> {
|
||||
let id = p.id.value().to_string();
|
||||
let user_id = p.user_id.value().to_string();
|
||||
let created_at = p.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let field_mappings = serialize_mappings(&p.field_mappings)?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&p.name)
|
||||
.bind(&field_mappings)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
use sqlx::Row;
|
||||
let id_str: String = r.get("id");
|
||||
let uid_str: String = r.get("user_id");
|
||||
let fm: String = r.get("field_mappings");
|
||||
let ca: String = r.get("created_at");
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
uid_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.get("name"),
|
||||
field_mappings: deserialize_mappings(&fm)?,
|
||||
created_at: Self::parse_dt(&ca)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportProfileId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportProfile>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&uid_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| {
|
||||
use sqlx::Row;
|
||||
let rid: String = r.get("id");
|
||||
let ruid: String = r.get("user_id");
|
||||
let fm: String = r.get("field_mappings");
|
||||
let ca: String = r.get("created_at");
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
rid.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
ruid.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.get("name"),
|
||||
field_mappings: deserialize_mappings(&fm)?,
|
||||
created_at: Self::parse_dt(&ca)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
sqlx::query("DELETE FROM import_profiles WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
FieldMapping,
|
||||
import::{DomainField, Transform},
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum DomainFieldJson {
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum TransformJson {
|
||||
RatingScale(f64),
|
||||
DateFormat(String),
|
||||
Identity,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) struct FieldMappingJson {
|
||||
source_column: String,
|
||||
domain_field: DomainFieldJson,
|
||||
transform: TransformJson,
|
||||
}
|
||||
|
||||
pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson {
|
||||
FieldMappingJson {
|
||||
source_column: m.source_column.clone(),
|
||||
domain_field: match &m.domain_field {
|
||||
DomainField::Title => DomainFieldJson::Title,
|
||||
DomainField::ReleaseYear => DomainFieldJson::ReleaseYear,
|
||||
DomainField::Director => DomainFieldJson::Director,
|
||||
DomainField::Rating => DomainFieldJson::Rating,
|
||||
DomainField::WatchedAt => DomainFieldJson::WatchedAt,
|
||||
DomainField::Comment => DomainFieldJson::Comment,
|
||||
DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId,
|
||||
},
|
||||
transform: match &m.transform {
|
||||
Transform::RatingScale(f) => TransformJson::RatingScale(*f),
|
||||
Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()),
|
||||
Transform::Identity => TransformJson::Identity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping {
|
||||
FieldMapping {
|
||||
source_column: j.source_column,
|
||||
domain_field: match j.domain_field {
|
||||
DomainFieldJson::Title => DomainField::Title,
|
||||
DomainFieldJson::ReleaseYear => DomainField::ReleaseYear,
|
||||
DomainFieldJson::Director => DomainField::Director,
|
||||
DomainFieldJson::Rating => DomainField::Rating,
|
||||
DomainFieldJson::WatchedAt => DomainField::WatchedAt,
|
||||
DomainFieldJson::Comment => DomainField::Comment,
|
||||
DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId,
|
||||
},
|
||||
transform: match j.transform {
|
||||
TransformJson::RatingScale(f) => Transform::RatingScale(f),
|
||||
TransformJson::DateFormat(s) => Transform::DateFormat(s),
|
||||
TransformJson::Identity => Transform::Identity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
|
||||
serde_json::to_string(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> =
|
||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
}
|
||||
211
crates/adapters/sqlite/src/repositories/import_session/mod.rs
Normal file
211
crates/adapters/sqlite/src/repositories/import_session/mod.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{AnnotatedRow, FieldMapping, ImportSession, ParsedFile},
|
||||
ports::ImportSessionRepository,
|
||||
value_objects::{ImportSessionId, UserId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod rows;
|
||||
use rows::*;
|
||||
|
||||
// ── repository ──
|
||||
|
||||
pub struct SqliteImportSessionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteImportSessionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_session(
|
||||
s: &ImportSession,
|
||||
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
let parsed = s
|
||||
.parsed_file
|
||||
.as_ref()
|
||||
.map(|f| {
|
||||
ser(&ParsedFileJson {
|
||||
columns: f.columns.clone(),
|
||||
rows: f.rows.clone(),
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mappings = s
|
||||
.field_mappings
|
||||
.as_ref()
|
||||
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
let results = s
|
||||
.row_results
|
||||
.as_ref()
|
||||
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
Ok((parsed, mappings, results))
|
||||
}
|
||||
|
||||
fn deserialize_session(
|
||||
id: String,
|
||||
user_id: String,
|
||||
parsed_data: String,
|
||||
field_mappings: Option<String>,
|
||||
row_results: Option<String>,
|
||||
created_at: &str,
|
||||
expires_at: &str,
|
||||
) -> Result<ImportSession, DomainError> {
|
||||
let parsed_file = if parsed_data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let j: ParsedFileJson = de(&parsed_data)?;
|
||||
Some(ParsedFile {
|
||||
columns: j.columns,
|
||||
rows: j.rows,
|
||||
})
|
||||
};
|
||||
let field_mappings = field_mappings
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> = de(s)?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
})
|
||||
.transpose()?;
|
||||
let row_results = row_results
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
|
||||
let js: Vec<AnnotatedRowJson> = de(s)?;
|
||||
Ok(js.into_iter().map(annotated_from_json).collect())
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(ImportSession {
|
||||
id: ImportSessionId::from_uuid(
|
||||
id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
parsed_file,
|
||||
field_mappings,
|
||||
row_results,
|
||||
created_at: Self::parse_dt(created_at)?,
|
||||
expires_at: Self::parse_dt(expires_at)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImportSessionRepository for SqliteImportSessionRepository {
|
||||
async fn create(&self, s: &ImportSession) -> Result<(), DomainError> {
|
||||
let id = s.id.value().to_string();
|
||||
let user_id = s.user_id.value().to_string();
|
||||
let created_at = s.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let expires_at = s.expires_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let (parsed_data, field_mappings, row_results) = Self::serialize_session(s)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&parsed_data)
|
||||
.bind(&field_mappings)
|
||||
.bind(&row_results)
|
||||
.bind(&created_at)
|
||||
.bind(&expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportSessionId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportSession>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
|
||||
FROM import_sessions WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&uid_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| {
|
||||
use sqlx::Row;
|
||||
Self::deserialize_session(
|
||||
r.get("id"),
|
||||
r.get("user_id"),
|
||||
r.get("parsed_data"),
|
||||
r.get("field_mappings"),
|
||||
r.get("row_results"),
|
||||
&r.get::<String, _>("created_at"),
|
||||
&r.get::<String, _>("expires_at"),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
|
||||
let id = s.id.value().to_string();
|
||||
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
|
||||
sqlx::query("UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?")
|
||||
.bind(&field_mappings)
|
||||
.bind(&row_results)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
sqlx::query("DELETE FROM import_sessions WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
|
||||
)
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
187
crates/adapters/sqlite/src/repositories/import_session/rows.rs
Normal file
187
crates/adapters/sqlite/src/repositories/import_session/rows.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
AnnotatedRow, FieldMapping,
|
||||
import::{DomainField, ImportRow, RowResult, Transform},
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── serde mirror structs (match the JSON format from the old importer types) ──
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub(super) struct ParsedFileJson {
|
||||
pub(super) columns: Vec<String>,
|
||||
pub(super) rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum DomainFieldJson {
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum TransformJson {
|
||||
RatingScale(f64),
|
||||
DateFormat(String),
|
||||
Identity,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) struct FieldMappingJson {
|
||||
source_column: String,
|
||||
domain_field: DomainFieldJson,
|
||||
transform: TransformJson,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub(super) struct ImportRowJson {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
release_year: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
director: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
watched_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_metadata_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum RowResultJson {
|
||||
Valid(ImportRowJson),
|
||||
Invalid {
|
||||
errors: Vec<String>,
|
||||
raw: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) struct AnnotatedRowJson {
|
||||
result: RowResultJson,
|
||||
is_duplicate: bool,
|
||||
}
|
||||
|
||||
// ── conversion helpers ──
|
||||
|
||||
pub(super) fn domain_field_to_json(f: &DomainField) -> DomainFieldJson {
|
||||
match f {
|
||||
DomainField::Title => DomainFieldJson::Title,
|
||||
DomainField::ReleaseYear => DomainFieldJson::ReleaseYear,
|
||||
DomainField::Director => DomainFieldJson::Director,
|
||||
DomainField::Rating => DomainFieldJson::Rating,
|
||||
DomainField::WatchedAt => DomainFieldJson::WatchedAt,
|
||||
DomainField::Comment => DomainFieldJson::Comment,
|
||||
DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn domain_field_from_json(j: DomainFieldJson) -> DomainField {
|
||||
match j {
|
||||
DomainFieldJson::Title => DomainField::Title,
|
||||
DomainFieldJson::ReleaseYear => DomainField::ReleaseYear,
|
||||
DomainFieldJson::Director => DomainField::Director,
|
||||
DomainFieldJson::Rating => DomainField::Rating,
|
||||
DomainFieldJson::WatchedAt => DomainField::WatchedAt,
|
||||
DomainFieldJson::Comment => DomainField::Comment,
|
||||
DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn transform_to_json(t: &Transform) -> TransformJson {
|
||||
match t {
|
||||
Transform::RatingScale(f) => TransformJson::RatingScale(*f),
|
||||
Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()),
|
||||
Transform::Identity => TransformJson::Identity,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn transform_from_json(j: TransformJson) -> Transform {
|
||||
match j {
|
||||
TransformJson::RatingScale(f) => Transform::RatingScale(f),
|
||||
TransformJson::DateFormat(s) => Transform::DateFormat(s),
|
||||
TransformJson::Identity => Transform::Identity,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson {
|
||||
FieldMappingJson {
|
||||
source_column: m.source_column.clone(),
|
||||
domain_field: domain_field_to_json(&m.domain_field),
|
||||
transform: transform_to_json(&m.transform),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping {
|
||||
FieldMapping {
|
||||
source_column: j.source_column,
|
||||
domain_field: domain_field_from_json(j.domain_field),
|
||||
transform: transform_from_json(j.transform),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_row_to_json(r: &ImportRow) -> ImportRowJson {
|
||||
ImportRowJson {
|
||||
title: r.title.clone(),
|
||||
release_year: r.release_year.clone(),
|
||||
director: r.director.clone(),
|
||||
rating: r.rating.clone(),
|
||||
watched_at: r.watched_at.clone(),
|
||||
comment: r.comment.clone(),
|
||||
external_metadata_id: r.external_metadata_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_row_from_json(j: ImportRowJson) -> ImportRow {
|
||||
ImportRow {
|
||||
title: j.title,
|
||||
release_year: j.release_year,
|
||||
director: j.director,
|
||||
rating: j.rating,
|
||||
watched_at: j.watched_at,
|
||||
comment: j.comment,
|
||||
external_metadata_id: j.external_metadata_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson {
|
||||
AnnotatedRowJson {
|
||||
result: match &a.result {
|
||||
RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)),
|
||||
RowResult::Invalid { errors, raw } => RowResultJson::Invalid {
|
||||
errors: errors.clone(),
|
||||
raw: raw.clone(),
|
||||
},
|
||||
},
|
||||
is_duplicate: a.is_duplicate,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow {
|
||||
AnnotatedRow {
|
||||
result: match j.result {
|
||||
RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)),
|
||||
RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw },
|
||||
},
|
||||
is_duplicate: j.is_duplicate,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ser<T: Serialize>(v: &T) -> Result<String, DomainError> {
|
||||
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
|
||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
88
crates/adapters/sqlite/src/repositories/insights/mod.rs
Normal file
88
crates/adapters/sqlite/src/repositories/insights/mod.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::insights::WatchRow, ports::InsightsQuery, value_objects::UserId,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::shared::{parse_datetime, parse_uuid};
|
||||
|
||||
/// SQLite read side of Insights (ADR-0010). Deliberately thin: one query for
|
||||
/// review basics, one for genres — every threshold and comparison lives in
|
||||
/// the pure analyzer, not here.
|
||||
pub struct SqliteInsightsQuery {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteInsightsQuery {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InsightsQuery for SqliteInsightsQuery {
|
||||
async fn get_watch_rows(&self, user_id: &UserId) -> Result<Vec<WatchRow>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT movie_id, rating, watched_at, watch_medium \
|
||||
FROM reviews \
|
||||
WHERE user_id = ? AND remote_actor_url IS NULL \
|
||||
ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
// Subquery rather than an `IN (?, …)` bind list: a whole diary can
|
||||
// exceed SQLite's bound-parameter limit.
|
||||
let genre_rows = sqlx::query(
|
||||
"SELECT movie_id, name FROM movie_genres \
|
||||
WHERE movie_id IN ( \
|
||||
SELECT movie_id FROM reviews \
|
||||
WHERE user_id = ? AND remote_actor_url IS NULL \
|
||||
) \
|
||||
ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let mut genres_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in genre_rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?;
|
||||
genres_map.entry(mid).or_default().push(name);
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let movie_id: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let rating: i64 = row
|
||||
.try_get("rating")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let watched_at: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let watch_medium: Option<String> = row
|
||||
.try_get("watch_medium")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
result.push(WatchRow {
|
||||
movie_id: parse_uuid(&movie_id)?,
|
||||
rating: rating as u8,
|
||||
watched_at: parse_datetime(&watched_at)?,
|
||||
watch_medium,
|
||||
genres: genres_map.get(&movie_id).cloned().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
47
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
47
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod ap_content;
|
||||
mod diary;
|
||||
mod goals;
|
||||
mod image_ref;
|
||||
mod import_profile;
|
||||
mod import_session;
|
||||
mod insights;
|
||||
mod movie;
|
||||
mod movie_dedup;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod refresh_sessions;
|
||||
mod remote_goals;
|
||||
mod review;
|
||||
mod search;
|
||||
mod social;
|
||||
mod stats;
|
||||
mod user_settings;
|
||||
mod users;
|
||||
mod watch_event;
|
||||
mod watchlist;
|
||||
mod wrapup;
|
||||
|
||||
pub use ap_content::SqliteApContentQuery;
|
||||
pub use diary::SqliteDiaryRepository;
|
||||
pub use goals::SqliteGoalRepository;
|
||||
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
|
||||
pub use import_profile::SqliteImportProfileRepository;
|
||||
pub use import_session::SqliteImportSessionRepository;
|
||||
pub use insights::SqliteInsightsQuery;
|
||||
pub use movie::SqliteMovieRepository;
|
||||
pub use movie_dedup::SqliteMovieDeduplicator;
|
||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||
pub use profile::SqliteMovieProfileRepository;
|
||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
||||
pub use review::SqliteReviewRepository;
|
||||
pub use search::create_search_adapter;
|
||||
pub use social::{SqliteSocialRepository, create_federated_profile_query};
|
||||
pub use stats::SqliteStatsRepository;
|
||||
pub use user_settings::SqliteUserSettingsRepository;
|
||||
pub use users::SqliteUserRepository;
|
||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
||||
pub use watchlist::SqliteWatchlistRepository;
|
||||
pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery};
|
||||
48
crates/adapters/sqlite/src/repositories/movie/command.rs
Normal file
48
crates/adapters/sqlite/src/repositories/movie/command.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::Movie, ports::MovieCommand, value_objects::MovieId};
|
||||
|
||||
use super::SqliteMovieRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieCommand for SqliteMovieRepository {
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||
let id = movie.id().value().to_string();
|
||||
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
|
||||
let title = movie.title().value();
|
||||
let release_year = movie.release_year().value() as i64;
|
||||
let director = movie.director();
|
||||
let poster_path = movie.poster_path().map(|p| p.value().to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = excluded.external_metadata_id,
|
||||
title = excluded.title,
|
||||
release_year = excluded.release_year,
|
||||
director = excluded.director,
|
||||
poster_path = excluded.poster_path",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&external_metadata_id)
|
||||
.bind(title)
|
||||
.bind(release_year)
|
||||
.bind(director)
|
||||
.bind(&poster_path)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query("DELETE FROM movies WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
15
crates/adapters/sqlite/src/repositories/movie/mod.rs
Normal file
15
crates/adapters/sqlite/src/repositories/movie/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod command;
|
||||
mod query;
|
||||
pub(crate) mod rows;
|
||||
|
||||
pub struct SqliteMovieRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteMovieRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
214
crates/adapters/sqlite/src/repositories/movie/query.rs
Normal file
214
crates/adapters/sqlite/src/repositories/movie/query.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
Movie, MovieFilter, MovieSummary,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::MovieQuery,
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use super::SqliteMovieRepository;
|
||||
use super::rows::{MovieRow, MovieSummaryRow};
|
||||
|
||||
#[async_trait]
|
||||
impl MovieQuery for SqliteMovieRepository {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
let id = external_metadata_id.value();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
let t = title.value();
|
||||
let y = year.value() as i64;
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE title = ? AND release_year = ?",
|
||||
)
|
||||
.bind(t)
|
||||
.bind(y)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn existing_external_ids(
|
||||
&self,
|
||||
ids: &[ExternalMetadataId],
|
||||
) -> Result<std::collections::HashSet<String>, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
|
||||
let sql = format!(
|
||||
"SELECT external_metadata_id FROM movies WHERE external_metadata_id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut q = sqlx::query_scalar::<_, String>(&sql);
|
||||
for id in ids {
|
||||
q = q.bind(id.value().to_string());
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn existing_title_year_pairs(
|
||||
&self,
|
||||
pairs: &[(MovieTitle, ReleaseYear)],
|
||||
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
|
||||
if pairs.is_empty() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
let conditions: Vec<String> = pairs
|
||||
.iter()
|
||||
.map(|_| "(title = ? AND release_year = ?)".to_string())
|
||||
.collect();
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT title, release_year FROM movies WHERE {}",
|
||||
conditions.join(" OR ")
|
||||
);
|
||||
use sqlx::Row;
|
||||
let mut q = sqlx::query(&sql);
|
||||
for (t, y) in pairs {
|
||||
q = q.bind(t.value().to_string()).bind(y.value() as i64);
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let t: String = r.get("title");
|
||||
let y: i64 = r.get("release_year");
|
||||
(t, y as u16)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_movies(
|
||||
&self,
|
||||
page: &PageParams,
|
||||
filter: &MovieFilter,
|
||||
) -> Result<Paginated<MovieSummary>, DomainError> {
|
||||
use sqlx::Row;
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let pattern = filter
|
||||
.search
|
||||
.as_deref()
|
||||
.map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let genre = filter.genre.as_deref();
|
||||
let language = filter.language.as_deref();
|
||||
|
||||
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
|
||||
"SELECT \
|
||||
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
|
||||
GROUP_CONCAT(g.name) AS genres \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
LEFT JOIN movie_genres g ON g.movie_id = m.id \
|
||||
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
|
||||
AND (? IS NULL OR p.original_language = ?) \
|
||||
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?))) \
|
||||
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
|
||||
ORDER BY m.title ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.bind(genre)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(DISTINCT m.id) \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
|
||||
AND (? IS NULL OR p.original_language = ?) \
|
||||
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?)))",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.bind(genre)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
68
crates/adapters/sqlite/src/repositories/movie/rows.rs
Normal file
68
crates/adapters/sqlite/src/repositories/movie/rows.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::shared::{movie_row_to_domain, movie_summary_to_domain};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{Movie, MovieSummary},
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieSummaryRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub genres: Option<String>,
|
||||
pub runtime_minutes: Option<i64>,
|
||||
pub original_language: Option<String>,
|
||||
pub overview: Option<String>,
|
||||
pub collection_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieSummaryRow {
|
||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
let genres = self
|
||||
.genres
|
||||
.map(|g| g.split(',').map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(movie_summary_to_domain(
|
||||
movie,
|
||||
genres,
|
||||
self.runtime_minutes,
|
||||
self.original_language,
|
||||
self.overview,
|
||||
self.collection_name,
|
||||
))
|
||||
}
|
||||
}
|
||||
142
crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs
Normal file
142
crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteMovieDeduplicator {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteMovieDeduplicator {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
async fn merge_into_canonical(
|
||||
&self,
|
||||
old_id: &MovieId,
|
||||
canonical: &Movie,
|
||||
) -> Result<u64, DomainError> {
|
||||
let old = old_id.value().to_string();
|
||||
let new = canonical.id().value().to_string();
|
||||
let ext_id = canonical
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string());
|
||||
let title = canonical.title().value().to_string();
|
||||
let year = canonical.release_year().value() as i64;
|
||||
let director = canonical.director().map(str::to_string);
|
||||
let poster = canonical.poster_path().map(|p| p.value().to_string());
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = COALESCE(excluded.external_metadata_id, movies.external_metadata_id),
|
||||
poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
|
||||
.execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name)
|
||||
SELECT ?, tmdb_id, name FROM movie_genres WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name)
|
||||
SELECT ?, tmdb_id, name FROM movie_keywords WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
|
||||
SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?",
|
||||
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
|
||||
SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?",
|
||||
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query("DELETE FROM movies WHERE id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
tx.commit().await.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(reviews + watchlist + watch_events + profiles)
|
||||
}
|
||||
}
|
||||
117
crates/adapters/sqlite/src/repositories/persons/command.rs
Normal file
117
crates/adapters/sqlite/src/repositories/persons/command.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{ExternalPersonId, Person, PersonEnrichmentData, PersonId},
|
||||
ports::PersonCommand,
|
||||
};
|
||||
|
||||
use super::SqlitePersonAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl PersonCommand for SqlitePersonAdapter {
|
||||
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
|
||||
for person in persons {
|
||||
let tmdb_id = person.external_id().tmdb_id();
|
||||
sqlx::query(
|
||||
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_id = excluded.external_id,
|
||||
tmdb_person_id = excluded.tmdb_person_id,
|
||||
name = excluded.name,
|
||||
known_for_department = excluded.known_for_department,
|
||||
profile_path = excluded.profile_path",
|
||||
)
|
||||
.bind(person.id().value().to_string())
|
||||
.bind(person.external_id().value())
|
||||
.bind(tmdb_id)
|
||||
.bind(person.name())
|
||||
.bind(person.known_for_department())
|
||||
.bind(person.profile_path())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
batch_size: u32,
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MissingPerson {
|
||||
tmdb_person_id: i64,
|
||||
name: String,
|
||||
department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, MissingPerson>(
|
||||
"SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path
|
||||
FROM movie_cast mc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
|
||||
GROUP BY mc.tmdb_person_id
|
||||
UNION ALL
|
||||
SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path
|
||||
FROM movie_crew mc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id)
|
||||
GROUP BY mc.tmdb_person_id
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(batch_size)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let has_more = rows.len() as u32 >= batch_size;
|
||||
let mut count = 0u64;
|
||||
for row in &rows {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id));
|
||||
let pid = PersonId::from_external(&ext);
|
||||
sqlx::query(
|
||||
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tmdb_person_id) DO NOTHING",
|
||||
)
|
||||
.bind(pid.value().to_string())
|
||||
.bind(ext.value())
|
||||
.bind(row.tmdb_person_id)
|
||||
.bind(&row.name)
|
||||
.bind(&row.department)
|
||||
.bind(&row.profile_path)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok((count, has_more))
|
||||
}
|
||||
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
id: &PersonId,
|
||||
data: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
let also_known_as_json =
|
||||
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&data.biography)
|
||||
.bind(data.birthday.map(|d| d.to_string()))
|
||||
.bind(data.deathday.map(|d| d.to_string()))
|
||||
.bind(&data.place_of_birth)
|
||||
.bind(&also_known_as_json)
|
||||
.bind(&data.homepage)
|
||||
.bind(&data.imdb_id)
|
||||
.bind(&now)
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
28
crates/adapters/sqlite/src/repositories/persons/mod.rs
Normal file
28
crates/adapters/sqlite/src/repositories/persons/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use domain::ports::{PersonCommand, PersonQuery};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub struct SqlitePersonAdapter {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqlitePersonAdapter {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
|
||||
let adapter = Arc::new(SqlitePersonAdapter::new(pool));
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
|
||||
adapter as Arc<dyn PersonQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
142
crates/adapters/sqlite/src/repositories/persons/query.rs
Normal file
142
crates/adapters/sqlite/src/repositories/persons/query.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
|
||||
ports::PersonQuery,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use super::SqlitePersonAdapter;
|
||||
use super::rows::{CastRow, CrewRow, PersonRow};
|
||||
|
||||
#[async_trait]
|
||||
impl PersonQuery for SqlitePersonAdapter {
|
||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?",
|
||||
)
|
||||
.bind(id.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let person = self
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
|
||||
|
||||
let tmdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.flatten();
|
||||
|
||||
let Some(tmdb_id) = tmdb_id else {
|
||||
return Ok(PersonCredits {
|
||||
person,
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
let cast = sqlx::query_as::<_, CastRow>(
|
||||
"SELECT m.id, m.title, m.release_year, mc.character, m.poster_path
|
||||
FROM movie_cast mc
|
||||
JOIN movies m ON m.id = mc.movie_id
|
||||
WHERE mc.tmdb_person_id = ?
|
||||
ORDER BY mc.billing_order",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CastCredit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
character: r.character,
|
||||
poster_path: r.poster_path,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let crew = sqlx::query_as::<_, CrewRow>(
|
||||
"SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path
|
||||
FROM movie_crew mc
|
||||
JOIN movies m ON m.id = mc.movie_id
|
||||
WHERE mc.tmdb_person_id = ?
|
||||
ORDER BY m.title",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CrewCredit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
job: r.job,
|
||||
department: r.department,
|
||||
poster_path: r.poster_path,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(PersonCredits { person, cast, crew })
|
||||
}
|
||||
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||
}
|
||||
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM persons
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id
|
||||
)
|
||||
LIMIT 500",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
72
crates/adapters/sqlite/src/repositories/persons/rows.rs
Normal file
72
crates/adapters/sqlite/src/repositories/persons/rows.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use domain::models::{ExternalPersonId, Person, PersonId};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PersonRow {
|
||||
pub(super) id: String,
|
||||
pub(super) external_id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) known_for_department: Option<String>,
|
||||
pub(super) profile_path: Option<String>,
|
||||
pub(super) biography: Option<String>,
|
||||
pub(super) birthday: Option<String>,
|
||||
pub(super) deathday: Option<String>,
|
||||
pub(super) place_of_birth: Option<String>,
|
||||
pub(super) also_known_as: Option<String>,
|
||||
pub(super) homepage: Option<String>,
|
||||
pub(super) imdb_id: Option<String>,
|
||||
pub(super) enriched_at: Option<String>,
|
||||
}
|
||||
|
||||
impl PersonRow {
|
||||
pub(super) fn into_person(self) -> Person {
|
||||
let ext = ExternalPersonId::new(self.external_id);
|
||||
let also_known_as = self
|
||||
.also_known_as
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let birthday = self
|
||||
.birthday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let deathday = self
|
||||
.deathday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let enriched_at = self
|
||||
.enriched_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|d| d.with_timezone(&chrono::Utc));
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
|
||||
ext,
|
||||
self.name,
|
||||
self.known_for_department,
|
||||
self.profile_path,
|
||||
self.biography,
|
||||
birthday,
|
||||
deathday,
|
||||
self.place_of_birth,
|
||||
also_known_as,
|
||||
self.homepage,
|
||||
self.imdb_id,
|
||||
enriched_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct CastRow {
|
||||
pub(super) id: String,
|
||||
pub(super) title: String,
|
||||
pub(super) release_year: Option<i64>,
|
||||
pub(super) character: String,
|
||||
pub(super) poster_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct CrewRow {
|
||||
pub(super) id: String,
|
||||
pub(super) title: String,
|
||||
pub(super) release_year: Option<i64>,
|
||||
pub(super) job: String,
|
||||
pub(super) department: String,
|
||||
pub(super) poster_path: Option<String>,
|
||||
}
|
||||
145
crates/adapters/sqlite/src/repositories/persons/tests.rs
Normal file
145
crates/adapters/sqlite/src/repositories/persons/tests.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use super::SqlitePersonAdapter;
|
||||
use domain::{
|
||||
models::{ExternalPersonId, Person, PersonId},
|
||||
ports::{PersonCommand, PersonQuery},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn pool_with_schema() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE persons (
|
||||
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
|
||||
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
|
||||
known_for_department TEXT, profile_path TEXT,
|
||||
biography TEXT, birthday TEXT, deathday TEXT,
|
||||
place_of_birth TEXT, also_known_as TEXT,
|
||||
homepage TEXT, imdb_id TEXT, enriched_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
|
||||
release_year INTEGER, director TEXT, poster_path TEXT,
|
||||
external_metadata_id TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT,
|
||||
PRIMARY KEY (movie_id, tmdb_person_id))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, job TEXT, department TEXT, profile_path TEXT,
|
||||
PRIMARY KEY (movie_id, tmdb_person_id, job))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
|
||||
Person::basic(
|
||||
PersonId::from_external(&ext),
|
||||
ext,
|
||||
name.to_string(),
|
||||
dept.map(str::to_string),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_batch_inserts_persons() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool.clone());
|
||||
|
||||
let persons = vec![
|
||||
make_person(1, "Alice", Some("Acting")),
|
||||
make_person(2, "Bob", Some("Directing")),
|
||||
];
|
||||
adapter.upsert_batch(&persons).await.unwrap();
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_batch_is_idempotent() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool.clone());
|
||||
|
||||
let persons = vec![make_person(1, "Alice", Some("Acting"))];
|
||||
adapter.upsert_batch(&persons).await.unwrap();
|
||||
adapter.upsert_batch(&persons).await.unwrap();
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_id_returns_person() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool.clone());
|
||||
|
||||
let p = make_person(42, "Charlie", Some("Acting"));
|
||||
adapter
|
||||
.upsert_batch(std::slice::from_ref(&p))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = adapter.get_by_id(p.id()).await.unwrap().unwrap();
|
||||
assert_eq!(found.name(), "Charlie");
|
||||
assert_eq!(found.external_id().value(), "tmdb:42");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_id_returns_none_for_unknown() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool);
|
||||
let ext = ExternalPersonId::new("tmdb:999");
|
||||
let id = PersonId::from_external(&ext);
|
||||
assert!(adapter.get_by_id(&id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_credits_returns_cast_and_crew() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool.clone());
|
||||
|
||||
let p = make_person(7, "Diana", Some("Acting"));
|
||||
adapter
|
||||
.upsert_batch(std::slice::from_ref(&p))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let credits = adapter.get_credits(p.id()).await.unwrap();
|
||||
assert_eq!(credits.person.name(), "Diana");
|
||||
assert_eq!(credits.cast.len(), 1);
|
||||
assert_eq!(credits.cast[0].character, "Hero");
|
||||
assert!(credits.crew.is_empty());
|
||||
}
|
||||
303
crates/adapters/sqlite/src/repositories/profile/mod.rs
Normal file
303
crates/adapters/sqlite/src/repositories/profile/mod.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
|
||||
ports::MovieProfileRepository,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteMovieProfileRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteMovieProfileRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
|
||||
let movie_id = p.movie_id.value().to_string();
|
||||
let enriched_at = p.enriched_at.to_rfc3339();
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO movie_profiles
|
||||
(movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes,
|
||||
budget_usd, revenue_usd, vote_average, vote_count,
|
||||
original_language, collection_name, enriched_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(movie_id) DO UPDATE SET
|
||||
tmdb_id=excluded.tmdb_id, imdb_id=excluded.imdb_id,
|
||||
overview=excluded.overview, tagline=excluded.tagline,
|
||||
runtime_minutes=excluded.runtime_minutes,
|
||||
budget_usd=excluded.budget_usd, revenue_usd=excluded.revenue_usd,
|
||||
vote_average=excluded.vote_average, vote_count=excluded.vote_count,
|
||||
original_language=excluded.original_language,
|
||||
collection_name=excluded.collection_name,
|
||||
enriched_at=excluded.enriched_at"#,
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(p.tmdb_id as i64)
|
||||
.bind(&p.imdb_id)
|
||||
.bind(&p.overview)
|
||||
.bind(&p.tagline)
|
||||
.bind(p.runtime_minutes.map(|v| v as i64))
|
||||
.bind(p.budget_usd)
|
||||
.bind(p.revenue_usd)
|
||||
.bind(p.vote_average)
|
||||
.bind(p.vote_count.map(|v| v as i64))
|
||||
.bind(&p.original_language)
|
||||
.bind(&p.collection_name)
|
||||
.bind(&enriched_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for g in &p.genres {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(g.tmdb_id as i64)
|
||||
.bind(&g.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for k in &p.keywords {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(k.tmdb_id as i64)
|
||||
.bind(&k.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for c in &p.cast {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_cast \
|
||||
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(c.tmdb_person_id as i64)
|
||||
.bind(&c.name)
|
||||
.bind(&c.character)
|
||||
.bind(c.billing_order as i64)
|
||||
.bind(&c.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for cr in &p.crew {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_crew \
|
||||
(movie_id, tmdb_person_id, name, job, department, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(cr.tmdb_person_id as i64)
|
||||
.bind(&cr.name)
|
||||
.bind(&cr.job)
|
||||
.bind(&cr.department)
|
||||
.bind(&cr.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||
let movie_id = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd,
|
||||
revenue_usd, vote_average, vote_count, original_language,
|
||||
collection_name, enriched_at
|
||||
FROM movie_profiles WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let enriched_at_str: String = row
|
||||
.try_get("enriched_at")
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
let enriched_at: DateTime<Utc> = enriched_at_str
|
||||
.parse()
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
|
||||
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Genre {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Keyword {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cast = sqlx::query(
|
||||
"SELECT tmdb_person_id, name, character, billing_order, profile_path \
|
||||
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CastMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
character: r.try_get("character").unwrap_or_default(),
|
||||
billing_order: r.try_get::<i64, _>("billing_order").unwrap_or(0) as u32,
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let crew = sqlx::query(
|
||||
"SELECT tmdb_person_id, name, job, department, profile_path \
|
||||
FROM movie_crew WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CrewMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
job: r.try_get("job").unwrap_or_default(),
|
||||
department: r.try_get("department").unwrap_or_default(),
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(MovieProfile {
|
||||
movie_id: id.clone(),
|
||||
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
|
||||
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
|
||||
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
|
||||
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
|
||||
runtime_minutes: row
|
||||
.try_get::<Option<i64>, _>("runtime_minutes")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
|
||||
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
|
||||
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
|
||||
vote_count: row
|
||||
.try_get::<Option<i64>, _>("vote_count")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
original_language: row
|
||||
.try_get::<Option<String>, _>("original_language")
|
||||
.ok()
|
||||
.flatten(),
|
||||
collection_name: row
|
||||
.try_get::<Option<String>, _>("collection_name")
|
||||
.ok()
|
||||
.flatten(),
|
||||
genres,
|
||||
keywords,
|
||||
cast,
|
||||
crew,
|
||||
enriched_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
|
||||
let threshold = (Utc::now() - chrono::Duration::days(30)).to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT m.id, m.external_metadata_id
|
||||
FROM movies m
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id
|
||||
WHERE m.external_metadata_id IS NOT NULL
|
||||
AND (p.movie_id IS NULL OR p.enriched_at < ?)
|
||||
ORDER BY p.enriched_at ASC"#,
|
||||
)
|
||||
.bind(&threshold)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let ext_id: Option<String> = r.try_get("external_metadata_id").ok()?;
|
||||
let ext_id = ext_id?;
|
||||
let id_str: String = r.try_get("id").ok()?;
|
||||
let movie_id = id_str.parse::<uuid::Uuid>().ok().map(MovieId::from_uuid)?;
|
||||
Some((movie_id, ext_id))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
95
crates/adapters/sqlite/src/repositories/profile/tests.rs
Normal file
95
crates/adapters/sqlite/src/repositories/profile/tests.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use super::SqliteMovieProfileRepository;
|
||||
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn pool_with_schema() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_profiles (
|
||||
movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT,
|
||||
overview TEXT, tagline TEXT, runtime_minutes INTEGER,
|
||||
budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL,
|
||||
vote_count INTEGER, original_language TEXT, collection_name TEXT,
|
||||
enriched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, job TEXT, department TEXT, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) {
|
||||
sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_cast_profile_path_becomes_none_not_empty_string() {
|
||||
let pool = pool_with_schema().await;
|
||||
let movie_id = MovieId::generate();
|
||||
let movie_id_str = movie_id.value().to_string();
|
||||
|
||||
insert_bare_profile(&pool, &movie_id_str).await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
|
||||
VALUES (?, 1, 'Alice', 'Hero', 0, NULL)",
|
||||
)
|
||||
.bind(&movie_id_str)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(profile.cast.len(), 1);
|
||||
assert_eq!(
|
||||
profile.cast[0].profile_path, None,
|
||||
"NULL profile_path must decode to None, not Some(\"\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_budget_usd_becomes_none_not_some_zero() {
|
||||
let pool = pool_with_schema().await;
|
||||
let movie_id = MovieId::generate();
|
||||
let movie_id_str = movie_id.value().to_string();
|
||||
|
||||
insert_bare_profile(&pool, &movie_id_str).await;
|
||||
|
||||
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
profile.budget_usd, None,
|
||||
"NULL budget_usd must decode to None, not Some(0)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct SqliteProfileFieldsRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteProfileFieldsRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
|
||||
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
|
||||
let id_str = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| ProfileField {
|
||||
name: r.get("name"),
|
||||
value: r.get("value"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_fields(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
fields: Vec<ProfileField>,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = user_id.value().to_string();
|
||||
|
||||
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
for (i, field) in fields.into_iter().enumerate() {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let position = i as i64;
|
||||
sqlx::query(
|
||||
"INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&id_str)
|
||||
.bind(&field.name)
|
||||
.bind(&field.value)
|
||||
.bind(position)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
107
crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs
Normal file
107
crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteRefreshSessionAdapter {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionAdapter {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session.id.to_string())
|
||||
.bind(session.user_id.value().to_string())
|
||||
.bind(&session.token)
|
||||
.bind(session.expires_at.to_rfc3339())
|
||||
.bind(session.created_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.map(RefreshSessionRow::into_domain).transpose()
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RefreshSessionRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
token: String,
|
||||
expires_at: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
impl RefreshSessionRow {
|
||||
fn into_domain(self) -> Result<RefreshSession, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&self.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?;
|
||||
let user_id = uuid::Uuid::parse_str(&self.user_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(&self.expires_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
let created_at = DateTime::parse_from_rfc3339(&self.created_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Ok(RefreshSession {
|
||||
id,
|
||||
user_id: UserId::from_uuid(user_id),
|
||||
token: self.token,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/remote_goals/mod.rs
Normal file
109
crates/adapters/sqlite/src/repositories/remote_goals/mod.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::TimeZone;
|
||||
use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteRemoteGoalRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRemoteGoalRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> {
|
||||
let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO remote_goals \
|
||||
(ap_id, actor_url, year, target_count, current_count, received_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&entry.ap_id)
|
||||
.bind(&entry.actor_url)
|
||||
.bind(entry.year as i64)
|
||||
.bind(entry.target_count as i64)
|
||||
.bind(entry.current_count as i64)
|
||||
.bind(&received)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_by_ap_id(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
target: u32,
|
||||
current: u32,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE remote_goals SET target_count = ?, current_count = ? WHERE ap_id = ?")
|
||||
.bind(target as i64)
|
||||
.bind(current as i64)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM remote_goals WHERE ap_id = ? AND actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM remote_goals WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, year, target_count, current_count, received_at \
|
||||
FROM remote_goals WHERE actor_url = ? ORDER BY year DESC",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let year: i64 = r.try_get("year").unwrap_or(0);
|
||||
let target: i64 = r.try_get("target_count").unwrap_or(0);
|
||||
let current: i64 = r.try_get("current_count").unwrap_or(0);
|
||||
let received_str: String = r.try_get("received_at").unwrap_or_default();
|
||||
let received_at =
|
||||
chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|ndt| chrono::Utc.from_utc_datetime(&ndt))
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
|
||||
Ok(RemoteGoalEntry {
|
||||
ap_id: r.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: r.try_get("actor_url").unwrap_or_default(),
|
||||
year: year as u16,
|
||||
target_count: target as u32,
|
||||
current_count: current as u32,
|
||||
received_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
122
crates/adapters/sqlite/src/repositories/review/mod.rs
Normal file
122
crates/adapters/sqlite/src/repositories/review/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use rows::ReviewRow;
|
||||
|
||||
pub(crate) mod rows;
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
pub struct SqliteReviewRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteReviewRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
let remote_actor_url = match review.source() {
|
||||
ReviewSource::Local => None,
|
||||
ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
.bind(&user_id)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query("DELETE FROM reviews WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
33
crates/adapters/sqlite/src/repositories/review/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/review/rows.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::shared::review_row_to_domain;
|
||||
use domain::{errors::DomainError, models::Review};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ReviewRow {
|
||||
pub id: String,
|
||||
pub movie_id: String,
|
||||
pub user_id: String,
|
||||
pub rating: i64,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||
review_row_to_domain(
|
||||
self.id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)
|
||||
}
|
||||
}
|
||||
133
crates/adapters/sqlite/src/repositories/search/command.rs
Normal file
133
crates/adapters/sqlite/src/repositories/search/command.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{EntityType, IndexableDocument},
|
||||
ports::SearchCommand,
|
||||
};
|
||||
|
||||
use super::SqliteSearchAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchCommand for SqliteSearchAdapter {
|
||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
||||
match doc {
|
||||
IndexableDocument::Movie { id, movie, profile } => {
|
||||
let movie_id = id.value().to_string();
|
||||
let title = movie.title().value().to_string();
|
||||
let director = movie.director().unwrap_or("").to_string();
|
||||
let release_year = movie.release_year().value() as i64;
|
||||
let (overview, genres, keywords, cast_names, crew_names, language) =
|
||||
match profile.as_deref() {
|
||||
Some(p) => (
|
||||
p.overview.clone().unwrap_or_default(),
|
||||
p.genres
|
||||
.iter()
|
||||
.map(|g| g.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.keywords
|
||||
.iter()
|
||||
.map(|k| k.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.cast
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.crew
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.original_language.clone().unwrap_or_default(),
|
||||
),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(&title)
|
||||
.bind(&director)
|
||||
.bind(&overview)
|
||||
.bind(&genres)
|
||||
.bind(&keywords)
|
||||
.bind(&cast_names)
|
||||
.bind(&crew_names)
|
||||
.bind(release_year)
|
||||
.bind(&language)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
IndexableDocument::Person { id, person } => {
|
||||
let person_id = id.value().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)",
|
||||
)
|
||||
.bind(&person_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(&person_id)
|
||||
.bind(person.name())
|
||||
.bind(person.known_for_department())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> {
|
||||
match entity_type {
|
||||
EntityType::Movie => {
|
||||
sqlx::query(
|
||||
"DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
EntityType::Person => {
|
||||
sqlx::query(
|
||||
"DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
27
crates/adapters/sqlite/src/repositories/search/mod.rs
Normal file
27
crates/adapters/sqlite/src/repositories/search/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use domain::ports::{SearchCommand, SearchPort};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod command;
|
||||
mod query;
|
||||
|
||||
pub struct SqliteSearchAdapter {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSearchAdapter {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
|
||||
let adapter = Arc::new(SqliteSearchAdapter::new(pool));
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
|
||||
adapter as Arc<dyn SearchPort>,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
244
crates/adapters/sqlite/src/repositories/search/query.rs
Normal file
244
crates/adapters/sqlite/src/repositories/search/query.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::PersonId,
|
||||
models::{MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, collections::Paginated},
|
||||
ports::SearchPort,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use super::SqliteSearchAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchPort for SqliteSearchAdapter {
|
||||
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
let movies = self.search_movies(query).await?;
|
||||
let people = self.search_people(query).await?;
|
||||
Ok(SearchResults { movies, people })
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteSearchAdapter {
|
||||
async fn search_movies(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<MovieSearchHit>, DomainError> {
|
||||
let limit = query.page.limit as i64;
|
||||
let offset = query.page.offset as i64;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
title: String,
|
||||
release_year: Option<i64>,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
genres: Option<String>,
|
||||
}
|
||||
|
||||
let total: u64 = if let Some(text) = &query.text {
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT m.id)
|
||||
FROM movies_fts fts
|
||||
JOIN movies m ON m.id = fts.movie_id
|
||||
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
|
||||
WHERE movies_fts MATCH ?
|
||||
AND (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
|
||||
AND (? IS NULL OR m.release_year = ?)",
|
||||
)
|
||||
.bind(&fts_query)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
count as u64
|
||||
} else {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT m.id)
|
||||
FROM movies m
|
||||
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
|
||||
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
|
||||
AND (? IS NULL OR m.release_year = ?)",
|
||||
)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
let rows: Vec<Row> = if let Some(text) = &query.text {
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||
GROUP_CONCAT(DISTINCT mg.name) AS genres
|
||||
FROM movies_fts fts
|
||||
JOIN movies m ON m.id = fts.movie_id
|
||||
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
|
||||
WHERE movies_fts MATCH ?
|
||||
AND (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
|
||||
AND (? IS NULL OR m.release_year = ?)
|
||||
GROUP BY m.id
|
||||
ORDER BY rank
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&fts_query)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||
GROUP_CONCAT(DISTINCT mg.name) AS genres
|
||||
FROM movies m
|
||||
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
|
||||
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
|
||||
AND (? IS NULL OR m.release_year = ?)
|
||||
GROUP BY m.id
|
||||
ORDER BY m.title
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(&query.filters.genre)
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
};
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| MovieSearchHit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
director: r.director,
|
||||
poster_path: r.poster_path,
|
||||
genres: r
|
||||
.genres
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_people(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<PersonSearchHit>, DomainError> {
|
||||
let Some(text) = &query.text else {
|
||||
return Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
});
|
||||
};
|
||||
|
||||
let limit = query.page.limit as i64;
|
||||
let offset = query.page.offset as i64;
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
|
||||
let total: u64 = {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?")
|
||||
.bind(&fts_query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
person_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT fts.person_id, p.name, p.known_for_department, p.profile_path
|
||||
FROM people_fts fts
|
||||
JOIN persons p ON p.id = fts.person_id
|
||||
WHERE people_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&fts_query)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let mut items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let tmdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
||||
.bind(&row.person_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.flatten();
|
||||
|
||||
let known_for_titles = if let Some(tid) = tmdb_id {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT m.title FROM movie_cast mc
|
||||
JOIN movies m ON m.id = mc.movie_id
|
||||
WHERE mc.tmdb_person_id = ?
|
||||
ORDER BY mc.billing_order
|
||||
LIMIT 3",
|
||||
)
|
||||
.bind(tid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
items.push(PersonSearchHit {
|
||||
person_id: PersonId::from_uuid(
|
||||
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(),
|
||||
),
|
||||
name: row.name,
|
||||
known_for_department: row.known_for_department,
|
||||
profile_path: row.profile_path,
|
||||
known_for_titles,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
213
crates/adapters/sqlite/src/repositories/search/tests.rs
Normal file
213
crates/adapters/sqlite/src/repositories/search/tests.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use super::create_search_adapter;
|
||||
use domain::{
|
||||
models::{
|
||||
EntityType, IndexableDocument, Movie, SearchFilters, SearchQuery, collections::PageParams,
|
||||
},
|
||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn pool_with_schema() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
|
||||
release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE,
|
||||
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
|
||||
known_for_department TEXT, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE VIRTUAL TABLE movies_fts USING fts5(
|
||||
movie_id UNINDEXED, title, director, overview, genres, keywords,
|
||||
cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE VIRTUAL TABLE people_fts USING fts5(
|
||||
person_id UNINDEXED, name, known_for_department UNINDEXED)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
fn test_movie(id: &str, title: &str, year: u16) -> Movie {
|
||||
Movie::from_persistence(
|
||||
MovieId::from_uuid(uuid::Uuid::parse_str(id).unwrap()),
|
||||
None,
|
||||
MovieTitle::new(title.into()).unwrap(),
|
||||
ReleaseYear::new(year).unwrap(),
|
||||
Some("Test Director".to_string()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn default_page() -> PageParams {
|
||||
PageParams::new(Some(10), Some(0)).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_and_search_movie_by_title() {
|
||||
let pool = pool_with_schema().await;
|
||||
let (cmd, query) = create_search_adapter(pool.clone());
|
||||
|
||||
let id_str = "00000000-0000-0000-0000-000000000001";
|
||||
let movie = test_movie(id_str, "Interstellar", 2014);
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str)
|
||||
.bind("Interstellar")
|
||||
.bind(2014i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: Some("Interstellar".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.movies.items.len(), 1);
|
||||
assert_eq!(results.movies.items[0].title, "Interstellar");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_movie_clears_from_index() {
|
||||
let pool = pool_with_schema().await;
|
||||
let (cmd, query) = create_search_adapter(pool.clone());
|
||||
|
||||
let id_str = "00000000-0000-0000-0000-000000000002";
|
||||
let movie = test_movie(id_str, "Inception", 2010);
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str)
|
||||
.bind("Inception")
|
||||
.bind(2010i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cmd.remove(EntityType::Movie, id_str).await.unwrap();
|
||||
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: Some("Inception".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(results.movies.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_with_genre_filter() {
|
||||
let pool = pool_with_schema().await;
|
||||
let (cmd, query) = create_search_adapter(pool.clone());
|
||||
|
||||
let id_str = "00000000-0000-0000-0000-000000000003";
|
||||
let movie = test_movie(id_str, "The Dark Knight", 2008);
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str)
|
||||
.bind("The Dark Knight")
|
||||
.bind(2008i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')")
|
||||
.bind(id_str)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Matching genre — no text filter
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters {
|
||||
genre: Some("Action".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.movies.items.len(), 1);
|
||||
|
||||
// Non-matching genre
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters {
|
||||
genre: Some("Comedy".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(results.movies.items.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for SqliteSocialRepository {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
let uid = synthetic_user_id.to_string();
|
||||
|
||||
let actor_url: Option<String> = sqlx::query_scalar(
|
||||
"SELECT remote_actor_url FROM reviews
|
||||
WHERE user_id = ? AND remote_actor_url IS NOT NULL
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let actor_url = match actor_url {
|
||||
Some(url) => url,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(&actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(FederatedProfile {
|
||||
actor_url,
|
||||
handle: r.get("handle"),
|
||||
display_name: r.try_get("display_name").ok().flatten(),
|
||||
bio: r.try_get("bio").ok().flatten(),
|
||||
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||
})),
|
||||
None => Ok(Some(FederatedProfile {
|
||||
handle: actor_url.clone(),
|
||||
actor_url,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
130
crates/adapters/sqlite/src/repositories/social/follow/command.rs
Normal file
130
crates/adapters/sqlite/src/repositories/social/follow/command.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{errors::DomainError, value_objects::FollowStatus};
|
||||
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
use super::infra_err;
|
||||
use super::rows::follow_status_to_str;
|
||||
use crate::repositories::social::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowCommand for SqliteSocialRepository {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
||||
VALUES (?1, ?2, '', ?3, ?4)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.bind(&now)
|
||||
.bind(status_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES (?1, ?2, ?3, ?4, '')
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
173
crates/adapters/sqlite/src/repositories/social/follow/query.rs
Normal file
173
crates/adapters/sqlite/src/repositories/social/follow/query.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, SocialActor},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::infra_err;
|
||||
use super::rows::{follow_status_from_str, social_actor_from_row};
|
||||
use crate::repositories::social::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
||||
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let uid = viewer_id.to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT (SELECT status FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS following,
|
||||
(SELECT status FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS followed_by",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
|
||||
Ok(FollowRelation {
|
||||
following: row
|
||||
.try_get::<Option<String>, _>("following")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
followed_by: row
|
||||
.try_get::<Option<String>, _>("followed_by")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialActor, SocialIdentity};
|
||||
use sqlx::Row;
|
||||
|
||||
pub(super) fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||
match status {
|
||||
FollowStatus::Pending => "pending",
|
||||
FollowStatus::Accepted => "accepted",
|
||||
FollowStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
|
||||
match status {
|
||||
"pending" => Some(FollowStatus::Pending),
|
||||
"accepted" => Some(FollowStatus::Accepted),
|
||||
"rejected" => Some(FollowStatus::Rejected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn social_actor_from_row(
|
||||
row: &sqlx::sqlite::SqliteRow,
|
||||
instance: &InstanceIdentity,
|
||||
) -> SocialActor {
|
||||
let actor_url: String = row.get("remote_actor_url");
|
||||
let identity = instance.identify(&actor_url);
|
||||
|
||||
let (handle, display_name, avatar_url) = match &identity {
|
||||
SocialIdentity::Local(_) => {
|
||||
let username: Option<String> = row.try_get("local_username").ok().flatten();
|
||||
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
||||
let avatar: Option<String> = row
|
||||
.try_get::<Option<String>, _>("local_avatar_path")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| instance.image_url_for(&p));
|
||||
let handle = username
|
||||
.as_deref()
|
||||
.map(|u| instance.handle_for(u))
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
(handle, display, avatar)
|
||||
}
|
||||
SocialIdentity::Remote { .. } => {
|
||||
let handle: String = row
|
||||
.try_get::<Option<String>, _>("remote_handle")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||
(handle, display, avatar)
|
||||
}
|
||||
};
|
||||
|
||||
SocialActor {
|
||||
identity,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederationAdminQuery for SqliteSocialRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||
FROM ap_remote_actors ar
|
||||
JOIN ap_following f ON f.remote_actor_url = ar.url
|
||||
WHERE f.status = 'accepted'",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(url, handle, display_name)| RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
27
crates/adapters/sqlite/src/repositories/social/mod.rs
Normal file
27
crates/adapters/sqlite/src/repositories/social/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
mod federated_profile;
|
||||
mod follow;
|
||||
mod local_social;
|
||||
mod watchlist;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteSocialRepository {
|
||||
pub(crate) pool: SqlitePool,
|
||||
pub(crate) instance: domain::value_objects::InstanceIdentity,
|
||||
}
|
||||
|
||||
impl SqliteSocialRepository {
|
||||
pub fn new(pool: SqlitePool, instance: domain::value_objects::InstanceIdentity) -> Self {
|
||||
Self { pool, instance }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_federated_profile_query(
|
||||
pool: SqlitePool,
|
||||
instance: domain::value_objects::InstanceIdentity,
|
||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||
std::sync::Arc::new(SqliteSocialRepository::new(pool, instance))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
253
crates/adapters/sqlite/src/repositories/social/tests.rs
Normal file
253
crates/adapters/sqlite/src/repositories/social/tests.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use super::*;
|
||||
use domain::ports::{FederationAdminQuery, FollowQuery};
|
||||
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
for ddl in [
|
||||
"CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||
"CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||||
display_name TEXT, avatar_path TEXT)",
|
||||
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
|
||||
display_name TEXT, avatar_url TEXT)",
|
||||
] {
|
||||
sqlx::query(ddl).execute(&pool).await.unwrap();
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
fn repo(pool: SqlitePool) -> SqliteSocialRepository {
|
||||
SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_returns_no_edges_for_strangers() {
|
||||
let r = repo(test_pool().await);
|
||||
let rel = r
|
||||
.get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rel.following, None);
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reads_following_direction_only_from_ap_following() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let target = "https://other.example/users/bob";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'pending')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(target)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Pending));
|
||||
assert_eq!(
|
||||
rel.followed_by, None,
|
||||
"an ap_following row must not populate followed_by"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() {
|
||||
let pool = test_pool().await;
|
||||
let owner = uuid::Uuid::new_v4();
|
||||
let requester = "https://other.example/users/carol";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'rejected')",
|
||||
)
|
||||
.bind(owner.to_string())
|
||||
.bind(requester)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(owner, requester).await.unwrap();
|
||||
|
||||
assert_eq!(rel.followed_by, Some(FollowStatus::Rejected));
|
||||
assert_eq!(rel.following, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_treats_unknown_status_as_no_edge() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let target = "https://other.example/users/dave";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'not-a-real-status')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(target)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||
|
||||
assert_eq!(rel.following, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_following_returns_only_pending_rows() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, 'https://other.example/users/pending', '', 'pending'),
|
||||
(?1, 'https://other.example/users/accepted', '', 'accepted')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
actors.len(),
|
||||
1,
|
||||
"accepted rows must not appear in pending_following"
|
||||
);
|
||||
// The handle-fallback-on-join-miss behavior is covered by
|
||||
// `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test
|
||||
// only needs to check pending-row filtering, so it asserts on identity.
|
||||
assert_eq!(
|
||||
actors[0].identity,
|
||||
SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/pending".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let orphan = "https://other.example/users/uncached";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'pending')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(orphan)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// deliberately NO ap_remote_actors row for `orphan`
|
||||
|
||||
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(
|
||||
actors[0].handle, orphan,
|
||||
"with no cached actor, handle must fall back to the actor url, not render empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_pending_followers_counts_only_pending() {
|
||||
let pool = test_pool().await;
|
||||
let owner = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, 'https://other.example/users/a', '', 'pending'),
|
||||
(?1, 'https://other.example/users/b', '', 'pending'),
|
||||
(?1, 'https://other.example/users/c', '', 'accepted'),
|
||||
(?1, 'https://other.example/users/d', '', 'rejected')",
|
||||
)
|
||||
.bind(owner.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let n = FollowQuery::count_pending_followers(&repo(pool), owner)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
n, 2,
|
||||
"only pending rows count; accepted and rejected must not"
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_admin_query_db(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
||||
url TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_following (
|
||||
local_user_id TEXT NOT NULL,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_followed_remote_actors_deduplicates() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup_admin_query_db(&pool).await;
|
||||
let repo =
|
||||
SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost"));
|
||||
let user1 = uuid::Uuid::new_v4();
|
||||
let user2 = uuid::Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name)
|
||||
VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
|
||||
(?, 'https://other.social/users/alice', 'act2', 'accepted')",
|
||||
)
|
||||
.bind(user1.to_string())
|
||||
.bind(user2.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let actors = repo.list_all_followed_remote_actors().await.unwrap();
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(actors[0].handle, "alice@other.social");
|
||||
}
|
||||
100
crates/adapters/sqlite/src/repositories/social/watchlist.rs
Normal file
100
crates/adapters/sqlite/src/repositories/social/watchlist.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for SqliteSocialRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i64).bind(&entry.external_metadata_id).bind(&entry.poster_url)
|
||||
.bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.execute(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC",
|
||||
).bind(actor_url).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let added_at_str: String = row.try_get("added_at").unwrap_or_default();
|
||||
let added_at =
|
||||
chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| {
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
dt,
|
||||
chrono::Utc,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let actors: Vec<String> =
|
||||
sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
243
crates/adapters/sqlite/src/repositories/stats/mod.rs
Normal file
243
crates/adapters/sqlite/src/repositories/stats/mod.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors,
|
||||
},
|
||||
ports::StatsRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use rows::{
|
||||
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||
UserTotalsRow, WatchMediumCountRow,
|
||||
};
|
||||
|
||||
mod rows;
|
||||
|
||||
pub struct SqliteStatsRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteStatsRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
||||
sqlx::query_as::<_, UserTotalsRow>(
|
||||
"SELECT COUNT(DISTINCT movie_id) AS total,
|
||||
AVG(CAST(rating AS REAL)) AS avg_rating
|
||||
FROM reviews WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_user_favorite_director(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
let row: Option<String> = sqlx::query_scalar(
|
||||
"SELECT m.director
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND m.director IS NOT NULL
|
||||
GROUP BY m.director
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn fetch_user_most_active_month(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
let row: Option<String> = sqlx::query_scalar(
|
||||
"SELECT strftime('%Y-%m', watched_at)
|
||||
FROM reviews
|
||||
WHERE user_id = ?
|
||||
GROUP BY strftime('%Y-%m', watched_at)
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StatsRepository for SqliteStatsRepository {
|
||||
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let (totals, fav_director, most_active) = tokio::try_join!(
|
||||
self.fetch_user_totals(&uid),
|
||||
self.fetch_user_favorite_director(&uid),
|
||||
self.fetch_user_most_active_month(&uid)
|
||||
)?;
|
||||
|
||||
let most_active_month = most_active.map(|ym| crate::shared::format_year_month(&ym));
|
||||
|
||||
Ok(UserStats {
|
||||
total_movies: totals.total,
|
||||
avg_rating: totals.avg_rating,
|
||||
favorite_director: fav_director,
|
||||
most_active_month,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::repositories::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) =
|
||||
tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT strftime('%Y-%m', watched_at) AS month,
|
||||
AVG(CAST(rating AS REAL)) AS avg_rating,
|
||||
COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
|
||||
GROUP BY month
|
||||
ORDER BY month ASC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, DirectorCountRow>(
|
||||
"SELECT m.director,
|
||||
COUNT(*) AS count
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND m.director IS NOT NULL
|
||||
GROUP BY m.director
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, GenreCountRow>(
|
||||
"SELECT mg.name AS genre, COUNT(*) AS count
|
||||
FROM reviews r
|
||||
INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
GROUP BY mg.name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, RatingDistRow>(
|
||||
"SELECT rating, COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = ?
|
||||
GROUP BY rating
|
||||
ORDER BY rating ASC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, WatchMediumCountRow>(
|
||||
"SELECT watch_medium, COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = ? AND watch_medium IS NOT NULL
|
||||
GROUP BY watch_medium
|
||||
ORDER BY COUNT(*) DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, ActorAppearanceRow>(
|
||||
"SELECT mc.tmdb_person_id, mc.name, mc.profile_path,
|
||||
mc.billing_order, mc.movie_id
|
||||
FROM reviews r
|
||||
INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
GROUP BY mc.tmdb_person_id, mc.movie_id",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
)
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||
|
||||
let monthly_ratings = rating_rows
|
||||
.into_iter()
|
||||
.map(|r| MonthlyRating {
|
||||
month_label: crate::shared::format_year_month(&r.month),
|
||||
year_month: r.month,
|
||||
avg_rating: r.avg_rating,
|
||||
count: r.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let top_directors = director_rows
|
||||
.into_iter()
|
||||
.map(|d| DirectorStat {
|
||||
director: d.director,
|
||||
count: d.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let top_genres = genre_rows
|
||||
.into_iter()
|
||||
.map(|g| domain::models::stats::GenreStat {
|
||||
genre: g.genre,
|
||||
count: g.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rating_distribution = {
|
||||
let mut dist = [0i64; 5];
|
||||
for r in &rating_dist_rows {
|
||||
let idx = (r.rating as usize).saturating_sub(1).min(4);
|
||||
dist[idx] = r.count;
|
||||
}
|
||||
dist
|
||||
};
|
||||
|
||||
let watch_medium_distribution = medium_rows
|
||||
.into_iter()
|
||||
.map(|m| domain::models::stats::WatchMediumStat {
|
||||
medium: m.watch_medium,
|
||||
count: m.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let top_actors = compute_top_actors(
|
||||
actor_rows
|
||||
.into_iter()
|
||||
.map(|r| ActorAppearance {
|
||||
tmdb_person_id: r.tmdb_person_id as u64,
|
||||
name: r.name,
|
||||
profile_path: r.profile_path,
|
||||
billing_order: r.billing_order as u32,
|
||||
movie_id: r.movie_id,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
Ok(UserTrends {
|
||||
monthly_ratings,
|
||||
top_directors,
|
||||
max_director_count,
|
||||
top_genres,
|
||||
rating_distribution,
|
||||
watch_medium_distribution,
|
||||
top_actors,
|
||||
})
|
||||
}
|
||||
}
|
||||
45
crates/adapters/sqlite/src/repositories/stats/rows.rs
Normal file
45
crates/adapters/sqlite/src/repositories/stats/rows.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct UserTotalsRow {
|
||||
pub total: i64,
|
||||
pub avg_rating: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct DirectorCountRow {
|
||||
pub director: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MonthlyRatingRow {
|
||||
pub month: String,
|
||||
pub avg_rating: f64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct GenreCountRow {
|
||||
pub genre: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct RatingDistRow {
|
||||
pub rating: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct WatchMediumCountRow {
|
||||
pub watch_medium: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ActorAppearanceRow {
|
||||
pub tmdb_person_id: i64,
|
||||
pub name: String,
|
||||
pub profile_path: Option<String>,
|
||||
pub billing_order: i32,
|
||||
pub movie_id: String,
|
||||
}
|
||||
102
crates/adapters/sqlite/src/repositories/user_settings/mod.rs
Normal file
102
crates/adapters/sqlite/src/repositories/user_settings/mod.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{FederationFlags, UserSettings},
|
||||
ports::{UserFederationSettingsQuery, UserSettingsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteUserSettingsRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserSettingsRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserSettingsRepository for SqliteUserSettingsRepository {
|
||||
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT federate_goals, federate_reviews, federate_watchlist \
|
||||
FROM user_settings WHERE user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
|
||||
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
|
||||
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
|
||||
Ok(UserSettings::from_persistence(
|
||||
user_id.clone(),
|
||||
goals != 0,
|
||||
reviews != 0,
|
||||
watchlist != 0,
|
||||
))
|
||||
}
|
||||
None => Ok(UserSettings::new(user_id.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
|
||||
let uid = settings.user_id().value().to_string();
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO user_settings \
|
||||
(user_id, federate_goals, federate_reviews, federate_watchlist) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(if settings.federate_goals() { 1i64 } else { 0 })
|
||||
.bind(if settings.federate_reviews() { 1i64 } else { 0 })
|
||||
.bind(if settings.federate_watchlist() {
|
||||
1i64
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserFederationSettingsQuery for SqliteUserSettingsRepository {
|
||||
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT federate_goals, federate_reviews, federate_watchlist \
|
||||
FROM user_settings WHERE user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
|
||||
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
|
||||
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
|
||||
Ok(FederationFlags {
|
||||
goals: goals != 0,
|
||||
reviews: reviews != 0,
|
||||
watchlist: watchlist != 0,
|
||||
})
|
||||
}
|
||||
None => Ok(FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
202
crates/adapters/sqlite/src/repositories/users/mod.rs
Normal file
202
crates/adapters/sqlite/src/repositories/users/mod.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use rows::UserSummaryRow;
|
||||
|
||||
mod rows;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{ProfileField, User, UserRole},
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct SqliteUserRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> UserRole {
|
||||
match s {
|
||||
"admin" => UserRole::Admin,
|
||||
_ => UserRole::Standard,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_user(
|
||||
row: &sqlx::sqlite::SqliteRow,
|
||||
profile_fields: Vec<ProfileField>,
|
||||
) -> Result<User, DomainError> {
|
||||
let id_str: String = row.try_get("id").unwrap_or_default();
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let email = Email::new(row.get("email"))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let username = Username::new(row.get("username"))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let hash = PasswordHash::new(row.get("password_hash"))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let role_str: String = row.get("role");
|
||||
Ok(User::from_persistence(
|
||||
UserId::from_uuid(id),
|
||||
email,
|
||||
username,
|
||||
hash,
|
||||
Self::parse_role(&role_str),
|
||||
domain::models::UserProfile {
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
bio: row.try_get("bio").ok().flatten(),
|
||||
avatar_path: row.try_get("avatar_path").ok().flatten(),
|
||||
banner_path: row.try_get("banner_path").ok().flatten(),
|
||||
also_known_as: row.try_get("also_known_as").ok().flatten(),
|
||||
profile_fields,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
const USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as";
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.value();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
|
||||
.bind(email_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let username_str = username.value();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
|
||||
.bind(username_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
if self.find_by_email(user.email()).await?.is_some() {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Email already registered".into(),
|
||||
));
|
||||
}
|
||||
if self.find_by_username(user.username()).await?.is_some() {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Username already taken".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = user.id().value().to_string();
|
||||
let email = user.email().value();
|
||||
let username = user.username().value();
|
||||
let hash = user.password_hash().value();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let role = match user.role() {
|
||||
UserRole::Admin => "admin",
|
||||
UserRole::Standard => "standard",
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(email)
|
||||
.bind(username)
|
||||
.bind(hash)
|
||||
.bind(&created_at)
|
||||
.bind(role)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let field_rows = sqlx::query(
|
||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let profile_fields = field_rows
|
||||
.iter()
|
||||
.map(|f| ProfileField {
|
||||
name: f.get("name"),
|
||||
value: f.get("value"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::row_to_user(&r, profile_fields).map(Some)
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
profile: &domain::models::UserProfile,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = user_id.value().to_string();
|
||||
sqlx::query(
|
||||
"UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&profile.display_name)
|
||||
.bind(&profile.bio)
|
||||
.bind(&profile.avatar_path)
|
||||
.bind(&profile.banner_path)
|
||||
.bind(&profile.also_known_as)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
sqlx::query_as::<_, UserSummaryRow>(
|
||||
r#"SELECT u.id, u.email, u.username, u.display_name,
|
||||
COUNT(DISTINCT r.movie_id) AS total_movies,
|
||||
AVG(CAST(r.rating AS REAL)) AS avg_rating,
|
||||
u.avatar_path
|
||||
FROM users u
|
||||
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
|
||||
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
|
||||
ORDER BY u.email ASC"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(UserSummaryRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
27
crates/adapters/sqlite/src/repositories/users/rows.rs
Normal file
27
crates/adapters/sqlite/src/repositories/users/rows.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::shared::user_summary_to_domain;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct UserSummaryRow {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub total_movies: i64,
|
||||
pub avg_rating: Option<f64>,
|
||||
pub avatar_path: Option<String>,
|
||||
}
|
||||
|
||||
impl UserSummaryRow {
|
||||
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
|
||||
user_summary_to_domain(
|
||||
self.id,
|
||||
self.email,
|
||||
self.username,
|
||||
self.display_name,
|
||||
self.total_movies,
|
||||
self.avg_rating,
|
||||
self.avatar_path,
|
||||
)
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/users/tests.rs
Normal file
109
crates/adapters/sqlite/src/repositories/users/tests.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use super::*;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::{Email, PasswordHash, Username};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let repo = SqliteUserRepository::new(pool.clone());
|
||||
(pool, repo)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_id_returns_none_when_not_found() {
|
||||
let (_, repo) = setup().await;
|
||||
let result = repo
|
||||
.find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_id_returns_user_when_found() {
|
||||
let (pool, repo) = setup().await;
|
||||
let id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind("test@example.com")
|
||||
.bind("test")
|
||||
.bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash")
|
||||
.bind("2026-01-01T00:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().email().value(), "test@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_profile_persists_bio_and_avatar() {
|
||||
let (_, repo) = setup().await;
|
||||
let user = domain::models::User::new(
|
||||
Email::new("test@example.com".to_string()).unwrap(),
|
||||
Username::new("testuser".to_string()).unwrap(),
|
||||
PasswordHash::new("hash".to_string()).unwrap(),
|
||||
UserRole::Standard,
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
|
||||
repo.update_profile(
|
||||
user.id(),
|
||||
&domain::models::UserProfile {
|
||||
bio: Some("My biography".to_string()),
|
||||
avatar_path: Some("avatars/user1".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||
assert_eq!(found.bio(), Some("My biography"));
|
||||
assert_eq!(found.avatar_path(), Some("avatars/user1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_profile_clears_fields_with_none() {
|
||||
let (_, repo) = setup().await;
|
||||
let user = domain::models::User::new(
|
||||
Email::new("test2@example.com".to_string()).unwrap(),
|
||||
Username::new("testuser2".to_string()).unwrap(),
|
||||
PasswordHash::new("hash".to_string()).unwrap(),
|
||||
UserRole::Standard,
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
repo.update_profile(
|
||||
user.id(),
|
||||
&domain::models::UserProfile {
|
||||
bio: Some("bio".to_string()),
|
||||
avatar_path: Some("path".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.update_profile(user.id(), &domain::models::UserProfile::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||
assert_eq!(found.bio(), None);
|
||||
assert_eq!(found.avatar_path(), None);
|
||||
}
|
||||
101
crates/adapters/sqlite/src/repositories/watch_event/command.rs
Normal file
101
crates/adapters/sqlite/src/repositories/watch_event/command.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{WatchEvent, WatchEventStatus},
|
||||
ports::WatchEventCommand,
|
||||
value_objects::WatchEventId,
|
||||
};
|
||||
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
use super::SqliteWatchEventRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventCommand for SqliteWatchEventRepository {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
let id = event.id().value().to_string();
|
||||
let user_id = event.user_id().value().to_string();
|
||||
let movie_id = event.movie_id().map(|m| m.value().to_string());
|
||||
let source = event.source().to_string();
|
||||
let watched_at = datetime_to_str(event.watched_at());
|
||||
let status = event.status().to_string();
|
||||
let created_at = datetime_to_str(event.created_at());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO watch_events \
|
||||
(id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(event.title())
|
||||
.bind(event.year().map(|y| y as i64))
|
||||
.bind(event.external_metadata_id())
|
||||
.bind(&source)
|
||||
.bind(&watched_at)
|
||||
.bind(&status)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WatchEventId,
|
||||
status: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let status_str = status.to_string();
|
||||
|
||||
sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?")
|
||||
.bind(&status_str)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
ids: &[WatchEventId],
|
||||
status: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
|
||||
let sql = format!(
|
||||
"UPDATE watch_events SET status = ? WHERE id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut q = sqlx::query(&sql).bind(status.to_string());
|
||||
for id in ids {
|
||||
q = q.bind(id.value().to_string());
|
||||
}
|
||||
let result = q
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let before_str = datetime_to_str(&before);
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
|
||||
.bind(&before_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
18
crates/adapters/sqlite/src/repositories/watch_event/mod.rs
Normal file
18
crates/adapters/sqlite/src/repositories/watch_event/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
mod webhook_token;
|
||||
|
||||
pub use webhook_token::SqliteWebhookTokenRepository;
|
||||
|
||||
pub struct SqliteWatchEventRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWatchEventRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
97
crates/adapters/sqlite/src/repositories/watch_event/query.rs
Normal file
97
crates/adapters/sqlite/src/repositories/watch_event/query.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::WatchEvent,
|
||||
ports::WatchEventQuery,
|
||||
value_objects::{UserId, WatchEventId},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
use super::SqliteWatchEventRepository;
|
||||
use super::rows::row_to_watch_event;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventQuery for SqliteWatchEventRepository {
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, watched_at, status, created_at \
|
||||
FROM watch_events \
|
||||
WHERE user_id = ? AND status = 'pending' \
|
||||
ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, watched_at, status, created_at \
|
||||
FROM watch_events WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_watch_event).transpose()
|
||||
}
|
||||
|
||||
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
|
||||
let sql = format!(
|
||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||
source, watched_at, status, created_at \
|
||||
FROM watch_events WHERE id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for id in ids {
|
||||
q = q.bind(id.value().to_string());
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
external_id: &str,
|
||||
after: chrono::NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let after_str = datetime_to_str(&after);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watch_events \
|
||||
WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(external_id)
|
||||
.bind(&after_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
111
crates/adapters/sqlite/src/repositories/watch_event/rows.rs
Normal file
111
crates/adapters/sqlite/src/repositories/watch_event/rows.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
||||
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
pub(super) fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
s.parse()
|
||||
.map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}")))
|
||||
}
|
||||
|
||||
pub(super) fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}")))
|
||||
}
|
||||
|
||||
pub(super) fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result<WatchEvent, DomainError> {
|
||||
let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?;
|
||||
let user_id_str: &str = row
|
||||
.try_get("user_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let movie_id_str: Option<&str> = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let title: String = row
|
||||
.try_get("title")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let year: Option<i64> = row.try_get("year").map_err(crate::shared::map_sqlx_error)?;
|
||||
let ext_id: Option<String> = row
|
||||
.try_get("external_metadata_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let source_str: String = row
|
||||
.try_get("source")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let watched_at_str: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let status_str: String = row
|
||||
.try_get("status")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let source: WatchEventSource = source_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
let status: WatchEventStatus = status_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let movie_id = movie_id_str
|
||||
.map(parse_uuid)
|
||||
.transpose()?
|
||||
.map(MovieId::from_uuid);
|
||||
|
||||
Ok(WatchEvent::from_persistence(PersistedWatchEvent {
|
||||
id: WatchEventId::from_uuid(parse_uuid(id_str)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(user_id_str)?),
|
||||
movie_id,
|
||||
title,
|
||||
year: year.map(|y| y as u16),
|
||||
external_metadata_id: ext_id,
|
||||
source,
|
||||
watched_at: parse_datetime(&watched_at_str)?,
|
||||
status,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn row_to_webhook_token(
|
||||
row: &sqlx::sqlite::SqliteRow,
|
||||
) -> Result<WebhookToken, DomainError> {
|
||||
let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?;
|
||||
let user_id_str: &str = row
|
||||
.try_get("user_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let token_hash: String = row
|
||||
.try_get("token_hash")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let provider_str: String = row
|
||||
.try_get("provider")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let label: Option<String> = row
|
||||
.try_get("label")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let last_used_str: Option<String> = row
|
||||
.try_get("last_used_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let provider: WatchEventSource = provider_str
|
||||
.parse()
|
||||
.map_err(|e: String| DomainError::InfrastructureError(e))?;
|
||||
|
||||
let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?;
|
||||
|
||||
Ok(WebhookToken::from_persistence(
|
||||
WebhookTokenId::from_uuid(parse_uuid(id_str)?),
|
||||
UserId::from_uuid(parse_uuid(user_id_str)?),
|
||||
token_hash,
|
||||
provider,
|
||||
label,
|
||||
parse_datetime(&created_at_str)?,
|
||||
last_used,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::WebhookToken,
|
||||
ports::WebhookTokenRepository,
|
||||
value_objects::{UserId, WebhookTokenId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
use super::rows::row_to_webhook_token;
|
||||
|
||||
pub struct SqliteWebhookTokenRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWebhookTokenRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebhookTokenRepository for SqliteWebhookTokenRepository {
|
||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> {
|
||||
let id = token.id().value().to_string();
|
||||
let user_id = token.user_id().value().to_string();
|
||||
let provider = token.provider().to_string();
|
||||
let created_at = datetime_to_str(token.created_at());
|
||||
let last_used = token.last_used_at().map(datetime_to_str);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO webhook_tokens \
|
||||
(id, user_id, token_hash, provider, label, created_at, last_used_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(token.token_hash())
|
||||
.bind(&provider)
|
||||
.bind(token.label())
|
||||
.bind(&created_at)
|
||||
.bind(&last_used)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \
|
||||
FROM webhook_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_webhook_token).transpose()
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \
|
||||
FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_webhook_token).collect()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?")
|
||||
.bind(&id_str)
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let now = datetime_to_str(&chrono::Utc::now().naive_utc());
|
||||
|
||||
sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
151
crates/adapters/sqlite/src/repositories/watchlist/mod.rs
Normal file
151
crates/adapters/sqlite/src/repositories/watchlist/mod.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use rows::WatchlistRow;
|
||||
|
||||
mod rows;
|
||||
use crate::shared::datetime_to_str;
|
||||
|
||||
pub struct SqliteWatchlistRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWatchlistRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
let id = entry.id.value().to_string();
|
||||
let user_id = entry.user_id.value().to_string();
|
||||
let movie_id = entry.movie_id.value().to_string();
|
||||
let added_at = datetime_to_str(&entry.added_at);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(&added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
"Watchlist entry for movie {} not found",
|
||||
mid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let rows: Vec<WatchlistRow> = sqlx::query_as(
|
||||
"SELECT w.id, w.user_id, w.movie_id, w.added_at, \
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \
|
||||
m.director, m.poster_path \
|
||||
FROM watchlist_entries w \
|
||||
JOIN movies m ON m.id = w.movie_id \
|
||||
WHERE w.user_id = ? \
|
||||
ORDER BY w.added_at DESC \
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
33
crates/adapters/sqlite/src/repositories/watchlist/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/watchlist/rows.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::shared::{
|
||||
movie_row_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain,
|
||||
};
|
||||
use domain::{errors::DomainError, models::WatchlistWithMovie};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct WatchlistRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub movie_id: String,
|
||||
pub added_at: String,
|
||||
pub m_id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?;
|
||||
let movie = movie_row_to_domain(
|
||||
self.m_id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
Ok(watchlist_with_movie_to_domain(entry, movie))
|
||||
}
|
||||
}
|
||||
200
crates/adapters/sqlite/src/repositories/wrapup/mod.rs
Normal file
200
crates/adapters/sqlite/src/repositories/wrapup/mod.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus},
|
||||
ports::WrapUpRepository,
|
||||
value_objects::WrapUpId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use rows::{row_to_record, status_to_str};
|
||||
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use query::SqliteWrapUpStatsQuery;
|
||||
|
||||
// ── WrapUpRepository ─────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SqliteWrapUpRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWrapUpRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WrapUpRepository for SqliteWrapUpRepository {
|
||||
async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> {
|
||||
let id = record.id.value().to_string();
|
||||
let user_id = record.user_id.map(|u| u.to_string());
|
||||
let status = status_to_str(&record.status);
|
||||
let start = record.start_date.format("%Y-%m-%d").to_string();
|
||||
let end = record.end_date.format("%Y-%m-%d").to_string();
|
||||
let created = record.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let completed = record
|
||||
.completed_at
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO wrap_up_records \
|
||||
(id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.bind(status)
|
||||
.bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok()))
|
||||
.bind(&record.error_message)
|
||||
.bind(&created)
|
||||
.bind(&completed)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
status: &WrapUpStatus,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let status_str = status_to_str(status);
|
||||
|
||||
sqlx::query("UPDATE wrap_up_records SET status = ?, error_message = ? WHERE id = ?")
|
||||
.bind(status_str)
|
||||
.bind(error)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let json = serde_json::to_string(report)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE wrap_up_records \
|
||||
SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&json)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WrapUpId) -> Result<Option<WrapUpRecord>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, start_date, end_date, status, report_json, error_message, \
|
||||
created_at, completed_at \
|
||||
FROM wrap_up_records WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_record).transpose()
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<WrapUpRecord>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, start_date, end_date, status, report_json, error_message, \
|
||||
created_at, completed_at \
|
||||
FROM wrap_up_records WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_record).collect()
|
||||
}
|
||||
|
||||
async fn list_global(&self) -> Result<Vec<WrapUpRecord>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, start_date, end_date, status, report_json, error_message, \
|
||||
created_at, completed_at \
|
||||
FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_record).collect()
|
||||
}
|
||||
|
||||
async fn find_existing(
|
||||
&self,
|
||||
user_id: Option<Uuid>,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Option<WrapUpRecord>, DomainError> {
|
||||
let uid = user_id.map(|u| u.to_string());
|
||||
let start_str = start.format("%Y-%m-%d").to_string();
|
||||
let end_str = end.format("%Y-%m-%d").to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, start_date, end_date, status, report_json, error_message, \
|
||||
created_at, completed_at \
|
||||
FROM wrap_up_records \
|
||||
WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?) \
|
||||
AND start_date = ? AND end_date = ? \
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&uid)
|
||||
.bind(&start_str)
|
||||
.bind(&end_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_record).transpose()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM wrap_up_records WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_failed_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let result =
|
||||
sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?")
|
||||
.bind(&before_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
288
crates/adapters/sqlite/src/repositories/wrapup/query.rs
Normal file
288
crates/adapters/sqlite/src/repositories/wrapup/query.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope},
|
||||
ports::WrapUpStatsQuery,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::shared::{parse_datetime, parse_uuid};
|
||||
|
||||
// ── WrapUpStatsQuery ─────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SqliteWrapUpStatsQuery {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWrapUpStatsQuery {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
|
||||
async fn get_reviews_with_profiles(
|
||||
&self,
|
||||
scope: &WrapUpScope,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<WrapUpMovieRow>, DomainError> {
|
||||
let start_str = range.start().format("%Y-%m-%d").to_string();
|
||||
let end_str = range.end().format("%Y-%m-%d").to_string();
|
||||
|
||||
// 1) Main query
|
||||
let (scope_clause, scope_bind) = match scope {
|
||||
WrapUpScope::User(uid) => ("AND r.user_id = ?", Some(uid.to_string())),
|
||||
WrapUpScope::Global => ("", None),
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
r.rating, r.watched_at, r.user_id, r.watch_medium, \
|
||||
p.runtime_minutes, p.budget_usd, p.original_language \
|
||||
FROM reviews r \
|
||||
INNER JOIN movies m ON m.id = r.movie_id \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
WHERE r.watched_at >= ? AND r.watched_at < ? {scope_clause} \
|
||||
ORDER BY r.watched_at ASC"
|
||||
);
|
||||
|
||||
let mut q = sqlx::query(&sql).bind(&start_str).bind(&end_str);
|
||||
if let Some(ref uid) = scope_bind {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
|
||||
let rows = q
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Collect unique movie IDs
|
||||
let mut movie_ids: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for row in &rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
if seen.insert(mid.clone()) {
|
||||
movie_ids.push(mid);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Batch-fetch genres, keywords, cast
|
||||
let (genres_map, keywords_map, cast_map) = tokio::try_join!(
|
||||
fetch_genres_sqlite(&self.pool, &movie_ids),
|
||||
fetch_keywords_sqlite(&self.pool, &movie_ids),
|
||||
fetch_cast_sqlite(&self.pool, &movie_ids),
|
||||
)?;
|
||||
|
||||
// 3) Build result
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let movie_id_str: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let title: String = row
|
||||
.try_get("title")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let release_year: i64 = row
|
||||
.try_get("release_year")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let director: Option<String> = row
|
||||
.try_get("director")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let poster_path: Option<String> = row
|
||||
.try_get("poster_path")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let rating: i64 = row
|
||||
.try_get("rating")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let watched_at_str: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let user_id_str: String = row
|
||||
.try_get("user_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let runtime_minutes: Option<i32> = row
|
||||
.try_get("runtime_minutes")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let budget_usd: Option<i64> = row
|
||||
.try_get("budget_usd")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let original_language: Option<String> = row
|
||||
.try_get("original_language")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let watch_medium: Option<String> = row
|
||||
.try_get("watch_medium")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
let cast = cast_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
|
||||
let cast_names: Vec<(String, u32, i64)> = cast
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.billing_order, c.tmdb_person_id))
|
||||
.collect();
|
||||
let cast_profile_paths: Vec<Option<String>> =
|
||||
cast.iter().map(|c| c.profile_path.clone()).collect();
|
||||
|
||||
result.push(WrapUpMovieRow {
|
||||
movie_id: parse_uuid(&movie_id_str)?,
|
||||
title,
|
||||
release_year: release_year as u16,
|
||||
director,
|
||||
poster_path,
|
||||
rating: rating as u8,
|
||||
watched_at: parse_datetime(&watched_at_str)?,
|
||||
user_id: parse_uuid(&user_id_str)?,
|
||||
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
||||
budget_usd,
|
||||
original_language,
|
||||
watch_medium,
|
||||
genres,
|
||||
keywords,
|
||||
cast_names,
|
||||
cast_profile_paths,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CastEntry {
|
||||
name: String,
|
||||
billing_order: u32,
|
||||
tmdb_person_id: i64,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
fn in_placeholders(n: usize) -> String {
|
||||
let mut s = String::new();
|
||||
for i in 0..n {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push('?');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Shared by WrapUp and Insights: movie-id → genre names.
|
||||
pub(crate) async fn fetch_genres_sqlite(
|
||||
pool: &SqlitePool,
|
||||
movie_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||
if movie_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT movie_id, name FROM movie_genres WHERE movie_id IN ({}) ORDER BY name",
|
||||
in_placeholders(movie_ids.len())
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for id in movie_ids {
|
||||
q = q.bind(id);
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(name);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn fetch_keywords_sqlite(
|
||||
pool: &SqlitePool,
|
||||
movie_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||
if movie_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT movie_id, name FROM movie_keywords WHERE movie_id IN ({}) ORDER BY name",
|
||||
in_placeholders(movie_ids.len())
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for id in movie_ids {
|
||||
q = q.bind(id);
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(name);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn fetch_cast_sqlite(
|
||||
pool: &SqlitePool,
|
||||
movie_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<CastEntry>>, DomainError> {
|
||||
if movie_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \
|
||||
FROM movie_cast \
|
||||
WHERE movie_id IN ({}) AND billing_order <= 3 \
|
||||
ORDER BY billing_order ASC",
|
||||
in_placeholders(movie_ids.len())
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for id in movie_ids {
|
||||
q = q.bind(id);
|
||||
}
|
||||
let rows = q
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let name: String = row.try_get("name").map_err(crate::shared::map_sqlx_error)?;
|
||||
let billing_order: i32 = row
|
||||
.try_get("billing_order")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let tmdb_person_id: i64 = row
|
||||
.try_get("tmdb_person_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let profile_path: Option<String> = row
|
||||
.try_get("profile_path")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(CastEntry {
|
||||
name,
|
||||
billing_order: billing_order as u32,
|
||||
tmdb_person_id,
|
||||
profile_path,
|
||||
});
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
82
crates/adapters/sqlite/src/repositories/wrapup/rows.rs
Normal file
82
crates/adapters/sqlite/src/repositories/wrapup/rows.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use chrono::NaiveDate;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::wrapup::{WrapUpRecord, WrapUpStatus},
|
||||
value_objects::WrapUpId,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::shared::{parse_datetime, parse_uuid};
|
||||
|
||||
pub(super) fn status_to_str(s: &WrapUpStatus) -> &'static str {
|
||||
match s {
|
||||
WrapUpStatus::Pending => "pending",
|
||||
WrapUpStatus::Generating => "generating",
|
||||
WrapUpStatus::Ready => "ready",
|
||||
WrapUpStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_status(s: &str) -> Result<WrapUpStatus, DomainError> {
|
||||
match s {
|
||||
"pending" => Ok(WrapUpStatus::Pending),
|
||||
"generating" => Ok(WrapUpStatus::Generating),
|
||||
"ready" => Ok(WrapUpStatus::Ready),
|
||||
"failed" => Ok(WrapUpStatus::Failed),
|
||||
other => Err(DomainError::InfrastructureError(format!(
|
||||
"Unknown wrap-up status: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_date(s: &str) -> Result<NaiveDate, DomainError> {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid date '{s}': {e}")))
|
||||
}
|
||||
|
||||
pub(super) fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result<WrapUpRecord, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?;
|
||||
let user_id_str: Option<String> = row
|
||||
.try_get("user_id")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let start_date_str: String = row
|
||||
.try_get("start_date")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let end_date_str: String = row
|
||||
.try_get("end_date")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let status_str: String = row
|
||||
.try_get("status")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let report_json: Option<String> = row
|
||||
.try_get("report_json")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let error_message: Option<String> = row
|
||||
.try_get("error_message")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let completed_at_str: Option<String> = row
|
||||
.try_get("completed_at")
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
|
||||
|
||||
let report = report_json.and_then(|j| serde_json::from_str(&j).ok());
|
||||
|
||||
Ok(WrapUpRecord {
|
||||
id: WrapUpId::from_uuid(parse_uuid(&id_str)?),
|
||||
user_id,
|
||||
start_date: parse_date(&start_date_str)?,
|
||||
end_date: parse_date(&end_date_str)?,
|
||||
status: parse_status(&status_str)?,
|
||||
report,
|
||||
error_message,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
completed_at: completed_at_str
|
||||
.as_deref()
|
||||
.map(parse_datetime)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user