refactor: sqlite repositories per aggregate

This commit is contained in:
2026-10-25 20:55:33 +01:00
parent 67597ec9a6
commit ab4e4c3138
65 changed files with 2500 additions and 2346 deletions

View 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()
}
}

View 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 ─────────────────────────────────────────────────

View 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;

View 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)
}
}

View 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,
],
)
}
}

View 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);
}

View 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,
))
}

View 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;

View 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();
}

View 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)
}
}

View File

@@ -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())
}

View 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)
}
}

View 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()))
}

View 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)
}
}

View 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};

View 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(())
}
}

View 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 }
}
}

View 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()
}
}

View 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,
))
}
}

View 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)
}
}

View 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(())
}
}

View 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;

View 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())
}
}

View 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>,
}

View 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());
}

View 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;

View 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)"
);
}

View File

@@ -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(())
}
}

View 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,
})
}
}

View 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()
}
}

View 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()
}
}

View 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,
)
}
}

View 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(())
}
}

View 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;

View 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,
})
}
}

View 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());
}

View File

@@ -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,
})),
}
}
}

View 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(())
}
}

View File

@@ -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())
}

View 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),
})
}
}

View File

@@ -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,
}
}

View File

@@ -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())
}
}

View 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;

View 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");
}

View 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,
}
}
}

View 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,
})
}
}

View 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,
}

View 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,
}),
}
}
}

View 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;

View 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,
)
}
}

View 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);
}

View 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())
}
}

View 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 }
}
}

View 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)
}
}

View 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,
))
}

View File

@@ -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(())
}
}

View 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)
}
}

View 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))
}
}

View 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())
}
}

View 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)
}

View 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()?,
})
}