fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled

This commit is contained in:
2026-05-13 23:38:57 +02:00
parent 7415b91e23
commit 19171806b9
142 changed files with 4140 additions and 2025 deletions

View File

@@ -1,5 +1,8 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
use domain::{
errors::DomainError,
ports::{ImageRefCommand, ImageRefQuery},
};
use sqlx::SqlitePool;
use std::sync::Arc;
@@ -15,23 +18,34 @@ impl SqliteImageRefAdapter {
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>)
(
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
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
.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
.bind(new_key)
.bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await
tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -14,12 +14,20 @@ use sqlx::SqlitePool;
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
enum TransformJson {
RatingScale(f64), DateFormat(String), Identity,
RatingScale(f64),
DateFormat(String),
Identity,
}
#[derive(Serialize, Deserialize)]
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
}
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()))?;
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())
}
@@ -85,7 +93,9 @@ pub struct SqliteImportProfileRepository {
}
impl SqliteImportProfileRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
@@ -95,7 +105,9 @@ impl SqliteImportProfileRepository {
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)))
.map_err(|e| {
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
})
}
}
@@ -109,7 +121,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
sqlx::query!(
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
VALUES (?, ?, ?, ?, ?)",
id, user_id, p.name, field_mappings, created_at
id,
user_id,
p.name,
field_mappings,
created_at
)
.execute(&self.pool)
.await
@@ -127,18 +143,31 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.await
.map_err(Self::map_err)?;
rows.into_iter().map(|r| {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?,
rows.into_iter()
.map(|r| {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?,
})
})
}).collect()
.collect()
}
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> {
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!(
@@ -151,13 +180,21 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
row.map(|r| {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?,
})
}).transpose()
})
.transpose()
}
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -22,7 +22,13 @@ struct ParsedFileJson {
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
@@ -41,19 +47,29 @@ struct FieldMappingJson {
#[derive(Serialize, Deserialize, Default)]
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>,
#[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)]
enum RowResultJson {
Valid(ImportRowJson),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
Invalid {
errors: Vec<String>,
raw: Vec<(String, String)>,
},
}
#[derive(Serialize, Deserialize)]
@@ -182,7 +198,9 @@ pub struct SqliteImportSessionRepository {
}
impl SqliteImportSessionRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
@@ -192,18 +210,33 @@ impl SqliteImportSessionRepository {
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)))
.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() }))
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()
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()
let results = s
.row_results
.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?;
Ok((parsed, mappings, results))
@@ -222,15 +255,20 @@ impl SqliteImportSessionRepository {
None
} else {
let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows })
Some(ParsedFile {
columns: j.columns,
rows: j.rows,
})
};
let field_mappings = field_mappings.as_deref()
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()
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())
@@ -239,10 +277,13 @@ impl SqliteImportSessionRepository {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
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()))?
user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
parsed_file,
field_mappings,
@@ -272,22 +313,35 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.map_err(Self::map_err)
}
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> {
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 = ?",
id_str, uid_str
id_str,
uid_str
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
row.map(|r| Self::deserialize_session(
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
&r.created_at, &r.expires_at,
)).transpose()
row.map(|r| {
Self::deserialize_session(
r.id,
r.user_id,
r.parsed_data,
r.field_mappings,
r.row_results,
&r.created_at,
&r.expires_at,
)
})
.transpose()
}
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
@@ -295,7 +349,9 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query!(
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
field_mappings, row_results, id
field_mappings,
row_results,
id
)
.execute(&self.pool)
.await
@@ -322,10 +378,13 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
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')", uid)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
sqlx::query!(
"DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
uid
)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
}

View File

@@ -402,11 +402,15 @@ impl MovieRepository for SqliteMovieRepository {
&self,
page: &domain::models::collections::PageParams,
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
) -> Result<domain::models::collections::Paginated<domain::models::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 pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
@@ -694,10 +698,7 @@ impl DiaryRepository for SqliteMovieRepository {
}
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
@@ -800,13 +801,10 @@ impl DiaryRepository for SqliteMovieRepository {
let limit = page.limit as i64;
let offset = page.offset as i64;
let total = sqlx::query_scalar!(
"SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
id_str
)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let total = sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -843,12 +841,11 @@ impl DiaryRepository for SqliteMovieRepository {
}
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(Self::map_err)?;
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
}
@@ -934,7 +931,9 @@ impl StatsRepository for SqliteMovieRepository {
}
}
pub async fn wire(database_url: &str) -> anyhow::Result<(
pub async fn wire(
database_url: &str,
) -> anyhow::Result<(
sqlx::SqlitePool,
std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>,
@@ -946,9 +945,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> {
use std::str::FromStr;
use anyhow::Context;
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")?
@@ -1073,8 +1072,9 @@ mod feed_filter_tests {
let repo = SqliteMovieRepository::new(pool);
let filter = FollowingFilter {
local_user_ids: vec![uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111")
.unwrap()],
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();
@@ -1147,7 +1147,10 @@ mod feed_filter_tests {
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");
assert_eq!(
result.items[0].user_email(),
"https://remote.social/users/carol"
);
}
#[tokio::test]
@@ -1209,8 +1212,12 @@ mod diary_count_tests {
.bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id).bind("Test Movie").bind(2024i32)
.execute(&pool).await.unwrap();
.bind(&movie_id)
.bind("Test Movie")
.bind(2024i32)
.execute(&pool)
.await
.unwrap();
// Local review (remote_actor_url IS NULL)
let r1 = uuid::Uuid::new_v4().to_string();

View File

@@ -1,7 +1,10 @@
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie},
models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,

View File

@@ -20,7 +20,10 @@ impl SqlitePersonAdapter {
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>)
(
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
adapter as Arc<dyn PersonQuery>,
)
}
fn map_err(e: sqlx::Error) -> DomainError {
@@ -70,7 +73,10 @@ impl PersonQuery for SqlitePersonAdapter {
Ok(row.map(PersonRow::into_person))
}
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
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 FROM persons WHERE external_id = ?",
)
@@ -83,21 +89,25 @@ impl PersonQuery for SqlitePersonAdapter {
}
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 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(map_err)?
.flatten();
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(map_err)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
return Ok(PersonCredits {
person,
cast: vec![],
crew: vec![],
});
};
let cast = sqlx::query_as::<_, CastRow>(

View File

@@ -66,48 +66,80 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
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(Self::map_err)?;
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(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
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(Self::map_err)?;
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(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
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(Self::map_err)?;
.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(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
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(Self::map_err)?;
.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(Self::map_err)?;
}
tx.commit().await.map_err(Self::map_err)
@@ -132,7 +164,8 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
None => return Ok(None),
};
let enriched_at_str: String = row.try_get("enriched_at")
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()
@@ -140,7 +173,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -150,7 +185,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -163,7 +200,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -179,7 +218,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
FROM movie_crew WHERE movie_id = ?",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -196,11 +237,19 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i64>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
runtime_minutes: row
.try_get::<Option<i64>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i64>, _>("vote_count").ok().flatten().map(|v| v as u32),
vote_count: row
.try_get::<Option<i64>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
genres,

View File

@@ -2,9 +2,7 @@ use async_trait::async_trait;
use sqlx::SqlitePool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
value_objects::UserId,
};
@@ -30,10 +28,20 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect())
Ok(rows
.into_iter()
.map(|r| ProfileField {
name: r.name,
value: r.value,
})
.collect())
}
async fn set_fields(&self, user_id: &UserId, fields: Vec<ProfileField>) -> Result<(), DomainError> {
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 = ?", id_str)

View File

@@ -40,7 +40,9 @@ async fn list_keys_returns_both_avatar_and_poster_paths() {
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();
.execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool);
let mut keys = adapter.list_keys().await.unwrap();
@@ -54,8 +56,12 @@ 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();
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());
@@ -73,7 +79,9 @@ async fn swap_updates_avatar_path() {
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();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
}
@@ -83,13 +91,17 @@ async fn swap_updates_poster_path() {
setup(&pool).await;
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool).await.unwrap();
.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();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
}
@@ -99,5 +111,8 @@ async fn swap_noop_when_key_not_found() {
setup(&pool).await;
let adapter = SqliteImageRefAdapter::new(pool);
adapter.swap("missing/key", "missing/key.avif").await.unwrap();
adapter
.swap("missing/key", "missing/key.avif")
.await
.unwrap();
}

View File

@@ -61,11 +61,16 @@ 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"))];
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();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 2);
}
@@ -79,7 +84,9 @@ async fn upsert_batch_is_idempotent() {
adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool).await.unwrap();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1);
}
@@ -114,9 +121,13 @@ async fn get_credits_returns_cast_and_crew() {
adapter.upsert_batch(&[p.clone()]).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
.execute(&pool).await.unwrap();
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
.execute(&pool).await.unwrap();
.execute(&pool)
.await
.unwrap();
let credits = adapter.get_credits(p.id()).await.unwrap();
assert_eq!(credits.person.name(), "Diana");

View File

@@ -36,7 +36,7 @@ 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 (?, ?, ?, ?, ?)"
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind("test@example.com")
@@ -88,10 +88,18 @@ async fn update_profile_clears_fields_with_none() {
UserRole::Standard,
);
repo.save(&user).await.unwrap();
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None)
repo.update_profile(
user.id(),
Some("bio".to_string()),
Some("path".to_string()),
None,
None,
)
.await
.unwrap();
repo.update_profile(user.id(), None, None, None, None)
.await
.unwrap();
repo.update_profile(user.id(), None, None, None, None).await.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None);

View File

@@ -177,7 +177,13 @@ impl UserRepository for SqliteUserRepository {
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect();
let profile_fields = field_rows
.into_iter()
.map(|f| ProfileField {
name: f.name,
value: f.value,
})
.collect();
Self::row_to_user(
r.id.unwrap_or_default(),
@@ -190,7 +196,8 @@ impl UserRepository for SqliteUserRepository {
r.banner_path,
r.also_known_as,
profile_fields,
).map(Some)
)
.map(Some)
}
async fn update_profile(

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::WatchlistRepository,
value_objects::{MovieId, UserId},
};
@@ -51,14 +54,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
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(Self::map_err)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
@@ -76,14 +78,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
) -> 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(Self::map_err)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0)
}
@@ -113,15 +114,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?")
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows
.into_iter()