90
crates/adapters/sqlite/src/insights.rs
Normal file
90
crates/adapters/sqlite/src/insights.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
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 adapter_common::{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(adapter_common::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(adapter_common::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(adapter_common::map_sqlx_error)?;
|
||||
let name: String = row
|
||||
.try_get("name")
|
||||
.map_err(adapter_common::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(adapter_common::map_sqlx_error)?;
|
||||
let rating: i64 = row
|
||||
.try_get("rating")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let watched_at: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let watch_medium: Option<String> = row
|
||||
.try_get("watch_medium")
|
||||
.map_err(adapter_common::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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod goals;
|
||||
mod image_ref;
|
||||
mod import_profile;
|
||||
mod import_session;
|
||||
mod insights;
|
||||
mod migrations;
|
||||
mod models;
|
||||
mod movie;
|
||||
@@ -25,6 +26,7 @@ pub use diary::SqliteDiaryRepository;
|
||||
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};
|
||||
@@ -64,6 +66,7 @@ pub struct SqliteWireOutput {
|
||||
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
|
||||
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
|
||||
pub insights: std::sync::Arc<dyn domain::ports::InsightsQuery>,
|
||||
pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
|
||||
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
|
||||
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
|
||||
@@ -114,6 +117,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
||||
ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())) as _,
|
||||
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
|
||||
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
|
||||
insights: std::sync::Arc::new(SqliteInsightsQuery::new(pool.clone())) as _,
|
||||
goal_command: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
|
||||
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
|
||||
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
|
||||
|
||||
@@ -99,7 +99,6 @@ impl ReviewRow {
|
||||
}
|
||||
}
|
||||
|
||||
// Used by query_diary JOIN — r.id aliased to review_id to avoid ambiguity with m.id
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct DiaryRow {
|
||||
pub id: String,
|
||||
@@ -173,7 +172,6 @@ impl MovieStatsRow {
|
||||
}
|
||||
}
|
||||
|
||||
// Like DiaryRow but includes user_email from JOIN with users table
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct FeedRow {
|
||||
pub id: String,
|
||||
|
||||
@@ -37,7 +37,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 1. Upsert canonical movie record
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -48,7 +47,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
|
||||
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 2. Re-point simple FK tables
|
||||
let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
@@ -73,7 +71,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
// 3. Re-point movie_profiles (PK — move only if canonical has none)
|
||||
let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = ? WHERE movie_id = ?")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
@@ -82,8 +79,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
// 4. Re-point enrichment tables with composite PKs (INSERT OR IGNORE + DELETE)
|
||||
// Canonical's existing rows win on conflict — old duplicates are discarded.
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name)
|
||||
SELECT ?, tmdb_id, name FROM movie_genres WHERE movie_id = ?",
|
||||
@@ -134,7 +129,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
|
||||
sqlx::query("DELETE FROM movies WHERE id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
|
||||
@@ -9,8 +9,6 @@ use sqlx::SqlitePool;
|
||||
async fn setup(pool: &SqlitePool) {
|
||||
sqlx::migrate!("./migrations").run(pool).await.unwrap();
|
||||
|
||||
// carol is a remote actor; we still need a non-null user_id for the schema,
|
||||
// so we create a local "ghost" user and link the remote review via remote_actor_url.
|
||||
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'),
|
||||
@@ -31,7 +29,6 @@ async fn setup(pool: &SqlitePool) {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// carol's review: local user_id=33333333, remote_actor_url set → remote review
|
||||
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),
|
||||
@@ -97,7 +94,7 @@ async fn test_following_filter() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2); // alice + carol, NOT bob
|
||||
assert_eq!(result.items.len(), 2);
|
||||
let titles: Vec<String> = result
|
||||
.items
|
||||
.iter()
|
||||
@@ -113,7 +110,6 @@ async fn test_get_movie_stats_local() {
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
// Inception: 1 local review, rating=5, no federated
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||
);
|
||||
@@ -122,8 +118,8 @@ async fn test_get_movie_stats_local() {
|
||||
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); // 5★ bucket
|
||||
assert_eq!(stats.rating_histogram[0], 0); // 1★ bucket
|
||||
assert_eq!(stats.rating_histogram[4], 1);
|
||||
assert_eq!(stats.rating_histogram[0], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -176,7 +172,6 @@ async fn test_get_movie_social_feed_pagination() {
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||
);
|
||||
// offset beyond results: total_count still correct, items empty
|
||||
let page = PageParams::new(Some(10), Some(5)).unwrap();
|
||||
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
|
||||
|
||||
@@ -190,7 +185,6 @@ async fn test_get_movie_stats_federated() {
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
// Dune: 1 federated review, rating=4
|
||||
let movie_id = domain::value_objects::MovieId::from_uuid(
|
||||
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
|
||||
);
|
||||
@@ -198,8 +192,8 @@ async fn test_get_movie_stats_federated() {
|
||||
|
||||
assert_eq!(stats.total_count, 1);
|
||||
assert_eq!(stats.federated_count, 1);
|
||||
assert_eq!(stats.rating_histogram[3], 1); // 4★ bucket
|
||||
assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket
|
||||
assert_eq!(stats.rating_histogram[3], 1);
|
||||
assert_eq!(stats.rating_histogram[4], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -208,7 +202,6 @@ async fn count_local_posts_excludes_remote_reviews() {
|
||||
setup(&pool).await;
|
||||
let repo = SqliteDiaryRepository::new(pool);
|
||||
|
||||
// setup() seeds 3 reviews: 2 local (alice, bob) + 1 remote (carol)
|
||||
let count = repo.count_local_posts().await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
@@ -437,7 +437,8 @@ fn in_placeholders(n: usize) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
async fn fetch_genres_sqlite(
|
||||
/// 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> {
|
||||
|
||||
Reference in New Issue
Block a user