refactor: sqlite repositories per aggregate
This commit is contained in:
303
crates/adapters/sqlite/src/repositories/profile/mod.rs
Normal file
303
crates/adapters/sqlite/src/repositories/profile/mod.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
|
||||
ports::MovieProfileRepository,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteMovieProfileRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteMovieProfileRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
|
||||
let movie_id = p.movie_id.value().to_string();
|
||||
let enriched_at = p.enriched_at.to_rfc3339();
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO movie_profiles
|
||||
(movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes,
|
||||
budget_usd, revenue_usd, vote_average, vote_count,
|
||||
original_language, collection_name, enriched_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(movie_id) DO UPDATE SET
|
||||
tmdb_id=excluded.tmdb_id, imdb_id=excluded.imdb_id,
|
||||
overview=excluded.overview, tagline=excluded.tagline,
|
||||
runtime_minutes=excluded.runtime_minutes,
|
||||
budget_usd=excluded.budget_usd, revenue_usd=excluded.revenue_usd,
|
||||
vote_average=excluded.vote_average, vote_count=excluded.vote_count,
|
||||
original_language=excluded.original_language,
|
||||
collection_name=excluded.collection_name,
|
||||
enriched_at=excluded.enriched_at"#,
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(p.tmdb_id as i64)
|
||||
.bind(&p.imdb_id)
|
||||
.bind(&p.overview)
|
||||
.bind(&p.tagline)
|
||||
.bind(p.runtime_minutes.map(|v| v as i64))
|
||||
.bind(p.budget_usd)
|
||||
.bind(p.revenue_usd)
|
||||
.bind(p.vote_average)
|
||||
.bind(p.vote_count.map(|v| v as i64))
|
||||
.bind(&p.original_language)
|
||||
.bind(&p.collection_name)
|
||||
.bind(&enriched_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for g in &p.genres {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(g.tmdb_id as i64)
|
||||
.bind(&g.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for k in &p.keywords {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(k.tmdb_id as i64)
|
||||
.bind(&k.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for c in &p.cast {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_cast \
|
||||
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(c.tmdb_person_id as i64)
|
||||
.bind(&c.name)
|
||||
.bind(&c.character)
|
||||
.bind(c.billing_order as i64)
|
||||
.bind(&c.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
for cr in &p.crew {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_crew \
|
||||
(movie_id, tmdb_person_id, name, job, department, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(cr.tmdb_person_id as i64)
|
||||
.bind(&cr.name)
|
||||
.bind(&cr.job)
|
||||
.bind(&cr.department)
|
||||
.bind(&cr.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||
let movie_id = id.value().to_string();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd,
|
||||
revenue_usd, vote_average, vote_count, original_language,
|
||||
collection_name, enriched_at
|
||||
FROM movie_profiles WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let enriched_at_str: String = row
|
||||
.try_get("enriched_at")
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
let enriched_at: DateTime<Utc> = enriched_at_str
|
||||
.parse()
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
|
||||
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Genre {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Keyword {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cast = sqlx::query(
|
||||
"SELECT tmdb_person_id, name, character, billing_order, profile_path \
|
||||
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CastMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
character: r.try_get("character").unwrap_or_default(),
|
||||
billing_order: r.try_get::<i64, _>("billing_order").unwrap_or(0) as u32,
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let crew = sqlx::query(
|
||||
"SELECT tmdb_person_id, name, job, department, profile_path \
|
||||
FROM movie_crew WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CrewMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
job: r.try_get("job").unwrap_or_default(),
|
||||
department: r.try_get("department").unwrap_or_default(),
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(MovieProfile {
|
||||
movie_id: id.clone(),
|
||||
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
|
||||
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
|
||||
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
|
||||
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
|
||||
runtime_minutes: row
|
||||
.try_get::<Option<i64>, _>("runtime_minutes")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
|
||||
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
|
||||
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
|
||||
vote_count: row
|
||||
.try_get::<Option<i64>, _>("vote_count")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
original_language: row
|
||||
.try_get::<Option<String>, _>("original_language")
|
||||
.ok()
|
||||
.flatten(),
|
||||
collection_name: row
|
||||
.try_get::<Option<String>, _>("collection_name")
|
||||
.ok()
|
||||
.flatten(),
|
||||
genres,
|
||||
keywords,
|
||||
cast,
|
||||
crew,
|
||||
enriched_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
|
||||
let threshold = (Utc::now() - chrono::Duration::days(30)).to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT m.id, m.external_metadata_id
|
||||
FROM movies m
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id
|
||||
WHERE m.external_metadata_id IS NOT NULL
|
||||
AND (p.movie_id IS NULL OR p.enriched_at < ?)
|
||||
ORDER BY p.enriched_at ASC"#,
|
||||
)
|
||||
.bind(&threshold)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let ext_id: Option<String> = r.try_get("external_metadata_id").ok()?;
|
||||
let ext_id = ext_id?;
|
||||
let id_str: String = r.try_get("id").ok()?;
|
||||
let movie_id = id_str.parse::<uuid::Uuid>().ok().map(MovieId::from_uuid)?;
|
||||
Some((movie_id, ext_id))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
95
crates/adapters/sqlite/src/repositories/profile/tests.rs
Normal file
95
crates/adapters/sqlite/src/repositories/profile/tests.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use super::SqliteMovieProfileRepository;
|
||||
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn pool_with_schema() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_profiles (
|
||||
movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT,
|
||||
overview TEXT, tagline TEXT, runtime_minutes INTEGER,
|
||||
budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL,
|
||||
vote_count INTEGER, original_language TEXT, collection_name TEXT,
|
||||
enriched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, job TEXT, department TEXT, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) {
|
||||
sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_cast_profile_path_becomes_none_not_empty_string() {
|
||||
let pool = pool_with_schema().await;
|
||||
let movie_id = MovieId::generate();
|
||||
let movie_id_str = movie_id.value().to_string();
|
||||
|
||||
insert_bare_profile(&pool, &movie_id_str).await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
|
||||
VALUES (?, 1, 'Alice', 'Hero', 0, NULL)",
|
||||
)
|
||||
.bind(&movie_id_str)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(profile.cast.len(), 1);
|
||||
assert_eq!(
|
||||
profile.cast[0].profile_path, None,
|
||||
"NULL profile_path must decode to None, not Some(\"\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_budget_usd_becomes_none_not_some_zero() {
|
||||
let pool = pool_with_schema().await;
|
||||
let movie_id = MovieId::generate();
|
||||
let movie_id_str = movie_id.value().to_string();
|
||||
|
||||
insert_bare_profile(&pool, &movie_id_str).await;
|
||||
|
||||
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
profile.budget_usd, None,
|
||||
"NULL budget_usd must decode to None, not Some(0)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user