refactor: merge sqlite adapter family into one crate

This commit is contained in:
2026-10-25 20:48:30 +01:00
parent 1ece8032ff
commit 67597ec9a6
62 changed files with 292 additions and 442 deletions

View File

@@ -27,13 +27,13 @@ impl SqliteDiaryRepository {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error),
.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(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
}
}
@@ -64,7 +64,7 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn fetch_movie_diary_rows(
@@ -96,7 +96,7 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn count_user_diary_entries(
@@ -127,7 +127,7 @@ impl SqliteDiaryRepository {
}
q.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn fetch_user_diary_rows(
@@ -173,7 +173,7 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
}
@@ -340,7 +340,7 @@ impl DiaryQuery for SqliteDiaryRepository {
let total = count_q
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
@@ -348,7 +348,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -373,7 +373,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?;
@@ -384,7 +384,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
@@ -405,7 +405,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
@@ -430,7 +430,7 @@ impl DiaryQuery for SqliteDiaryRepository {
while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row {
Ok(r) => r.into_domain(),
Err(e) => Err(adapter_common::map_sqlx_error(e)),
Err(e) => Err(crate::shared::map_sqlx_error(e)),
};
}
})
@@ -453,7 +453,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
.map(MovieStatsRow::into_domain)
}
@@ -471,7 +471,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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,
@@ -492,7 +492,7 @@ impl DiaryQuery for SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -512,7 +512,7 @@ impl DiaryQuery for SqliteDiaryRepository {
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(count as u64)
}
}

View File

@@ -0,0 +1,250 @@
mod payload;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use futures::stream::{self, BoxStream};
use sqlx::SqlitePool;
use tokio::sync::{Mutex, mpsc};
use payload::DbEventPayload;
pub struct DbEventQueueConfig {
pub poll_interval_ms: u64,
pub batch_size: i64,
pub max_attempts: i32,
}
impl DbEventQueueConfig {
pub fn from_env() -> Self {
Self {
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500),
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
}
}
}
#[derive(Clone)]
pub struct SqliteEventQueue {
pool: SqlitePool,
config: Arc<DbEventQueueConfig>,
}
impl SqliteEventQueue {
pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
Ok(Self {
pool,
config: Arc::new(config),
})
}
pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result<Arc<dyn EventPublisher>> {
let q = Self::create(pool, DbEventQueueConfig::from_env()).await?;
Ok(Arc::new(q))
}
pub async fn create_channel(
pool: SqlitePool,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
let q = Self::create(pool, DbEventQueueConfig::from_env()).await?;
Ok((Arc::new(q.clone()), Arc::new(q)))
}
}
#[async_trait]
impl EventPublisher for SqliteEventQueue {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let db_payload = DbEventPayload::from(event);
let event_type = db_payload.event_type();
let payload_json = serde_json::to_string(&db_payload)
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES (?, ?)")
.bind(event_type)
.bind(payload_json)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
Ok(())
}
}
impl EventConsumer for SqliteEventQueue {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let pool = self.pool.clone();
let config = Arc::clone(&self.config);
let (tx, rx) = mpsc::channel(128);
let rx = Arc::new(Mutex::new(rx));
tokio::spawn(async move {
let poll_interval = Duration::from_millis(config.poll_interval_ms);
loop {
match claim_batch(&pool, &config).await {
Err(e) => {
tracing::error!("sqlite event queue claim error: {e}");
tokio::time::sleep(poll_interval).await;
}
Ok(rows) if rows.is_empty() => {
tokio::time::sleep(poll_interval).await;
}
Ok(rows) => {
for row in rows {
let envelope = decode_row(&pool, row, config.max_attempts);
if tx.send(envelope).await.is_err() {
tracing::info!("sqlite event queue consumer closed");
return;
}
}
// no sleep — re-poll immediately when batch was non-empty
}
}
}
});
Box::pin(stream::unfold(rx, |rx| async move {
let item = rx.lock().await.recv().await?;
Some((item, rx))
}))
}
}
// ── Internal types ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct QueueRow {
id: i64,
payload: String,
attempts: i32,
}
async fn claim_batch(
pool: &SqlitePool,
config: &DbEventQueueConfig,
) -> Result<Vec<QueueRow>, DomainError> {
let mut tx = pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?;
let rows = sqlx::query_as::<_, QueueRow>(
"SELECT id, payload, attempts FROM event_queue
WHERE status = 'pending'
AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY next_attempt_at ASC
LIMIT ?",
)
.bind(config.batch_size)
.fetch_all(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(format!("select pending: {e}")))?;
if rows.is_empty() {
tx.rollback().await.ok();
return Ok(vec![]);
}
let placeholders = rows.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
let sql = format!(
"UPDATE event_queue SET status = 'processing' WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query(&sql);
for r in &rows {
q = q.bind(r.id);
}
q.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?;
tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?;
Ok(rows)
}
fn decode_row(
pool: &SqlitePool,
row: QueueRow,
max_attempts: i32,
) -> Result<EventEnvelope, DomainError> {
let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(db_payload)?;
Ok(EventEnvelope::new(
event,
Box::new(DbAckHandle {
pool: pool.clone(),
row_id: row.id,
attempts: row.attempts,
max_attempts,
}),
))
}
struct DbAckHandle {
pool: SqlitePool,
row_id: i64,
attempts: i32,
max_attempts: i32,
}
#[async_trait]
impl AckHandle for DbAckHandle {
async fn ack(&self) -> Result<(), DomainError> {
sqlx::query("UPDATE event_queue SET status = 'done' WHERE id = ?")
.bind(self.row_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(format!("ack: {e}")))?;
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
let new_attempts = self.attempts + 1;
if new_attempts >= self.max_attempts {
sqlx::query(
"UPDATE event_queue SET status = 'dead_lettered', attempts = ?, last_error = 'max attempts reached' WHERE id = ?"
)
.bind(new_attempts)
.bind(self.row_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(format!("nack dead-letter: {e}")))?;
} else {
let backoff = backoff_seconds(new_attempts);
let sql = format!(
"UPDATE event_queue SET status = 'pending', attempts = ?, next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+{backoff} seconds'), last_error = 'nack' WHERE id = ?"
);
sqlx::query(&sql)
.bind(new_attempts)
.bind(self.row_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(format!("nack retry: {e}")))?;
}
Ok(())
}
}
fn backoff_seconds(attempts: i32) -> i64 {
let base: i64 = 5 * (1i64 << attempts.min(6));
base.min(300)
}

View File

@@ -0,0 +1 @@
pub use event_payload::EventPayload as DbEventPayload;

View File

@@ -39,7 +39,7 @@ impl GoalCommand for SqliteGoalRepository {
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -53,7 +53,7 @@ impl GoalCommand for SqliteGoalRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
@@ -70,7 +70,7 @@ impl GoalCommand for SqliteGoalRepository {
.bind(&uid)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
@@ -97,7 +97,7 @@ impl GoalQuery for SqliteGoalRepository {
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.map(|r| row_to_goal(&r)).transpose()
}
@@ -112,7 +112,7 @@ impl GoalQuery for SqliteGoalRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_goal).collect()
}

View File

@@ -125,7 +125,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
@@ -136,7 +136,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter()
.map(|r| {
@@ -178,7 +178,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.map(|r| {
use sqlx::Row;
@@ -210,6 +210,6 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
}

View File

@@ -311,7 +311,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn get(
@@ -329,7 +329,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.map(|r| {
use sqlx::Row;
@@ -356,7 +356,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -366,14 +366,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected())
}
@@ -386,6 +386,6 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
}

View File

@@ -6,7 +6,7 @@ use domain::{
};
use sqlx::{Row, SqlitePool};
use adapter_common::{parse_datetime, parse_uuid};
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
@@ -33,7 +33,7 @@ impl InsightsQuery for SqliteInsightsQuery {
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
// Subquery rather than an `IN (?, …)` bind list: a whole diary can
// exceed SQLite's bound-parameter limit.
@@ -48,16 +48,14 @@ impl InsightsQuery for SqliteInsightsQuery {
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
.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);
}
@@ -65,16 +63,16 @@ impl InsightsQuery for SqliteInsightsQuery {
for row in &rows {
let movie_id: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let watched_at: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
result.push(WatchRow {
movie_id: parse_uuid(&movie_id)?,

View File

@@ -1,6 +1,7 @@
use sqlx::SqlitePool;
mod diary;
mod event_queue;
mod goals;
mod image_ref;
mod import_profile;
@@ -15,6 +16,9 @@ mod profile;
mod profile_fields;
mod refresh_sessions;
mod review;
mod search;
mod shared;
mod social;
mod stats;
mod user_settings;
mod users;
@@ -23,6 +27,7 @@ mod watchlist;
mod wrapup;
pub use diary::SqliteDiaryRepository;
pub use event_queue::{DbEventQueueConfig, SqliteEventQueue};
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
@@ -34,13 +39,23 @@ pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use refresh_sessions::SqliteRefreshSessionAdapter;
pub use review::SqliteReviewRepository;
pub use sqlite_social::SqliteApContentQuery;
pub use search::create_search_adapter;
pub use social::{
SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository,
create_federated_profile_query,
};
pub use stats::SqliteStatsRepository;
pub use users::SqliteUserRepository;
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
pub use watchlist::SqliteWatchlistRepository;
pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery};
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
/// here because it depends on `activitypub`/`k-ap`).
pub mod persistence {
pub use crate::shared::datetime_to_str;
}
pub fn create_profile_fields_repo(
pool: sqlx::SqlitePool,
) -> std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository> {
@@ -122,9 +137,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(sqlite_social::SqliteRemoteGoalRepository::new(
pool.clone(),
)) as _,
remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _,
deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _,
})
}

View File

@@ -1,4 +1,4 @@
use adapter_common::{
use crate::shared::{
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
user_summary_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain,
};

View File

@@ -50,7 +50,7 @@ impl MovieCommand for SqliteMovieRepository {
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -61,7 +61,7 @@ impl MovieCommand for SqliteMovieRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
}
@@ -80,7 +80,7 @@ impl MovieQuery for SqliteMovieRepository {
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
@@ -94,7 +94,7 @@ impl MovieQuery for SqliteMovieRepository {
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
@@ -114,7 +114,7 @@ impl MovieQuery for SqliteMovieRepository {
.bind(y)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
@@ -139,7 +139,7 @@ impl MovieQuery for SqliteMovieRepository {
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows.into_iter().collect())
}
@@ -166,7 +166,7 @@ impl MovieQuery for SqliteMovieRepository {
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| {
@@ -218,7 +218,7 @@ impl MovieQuery for SqliteMovieRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
@@ -236,7 +236,7 @@ impl MovieQuery for SqliteMovieRepository {
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
@@ -260,7 +260,7 @@ impl MovieQuery for SqliteMovieRepository {
)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()

View File

@@ -35,7 +35,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
@@ -45,14 +45,14 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
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(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.rows_affected();
let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?")
@@ -60,7 +60,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.rows_affected();
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?")
@@ -68,7 +68,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.rows_affected();
let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = ? WHERE movie_id = ?")
@@ -76,7 +76,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.rows_affected();
sqlx::query(
@@ -87,12 +87,12 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name)
@@ -102,40 +102,40 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
).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(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
).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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query("DELETE FROM movies WHERE id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)?;
Ok(reviews + watchlist + watch_events + profiles)
}

View File

@@ -52,7 +52,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
}
Ok(())
}
@@ -85,7 +85,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(batch_size)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64;
@@ -105,7 +105,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(&row.profile_path)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
count += 1;
}
Ok((count, has_more))
@@ -133,7 +133,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
}
@@ -147,7 +147,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -162,7 +162,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -178,7 +178,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
@@ -199,7 +199,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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()),
@@ -220,7 +220,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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()),
@@ -243,7 +243,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect())
}
@@ -261,7 +261,7 @@ impl PersonQuery for SqlitePersonAdapter {
)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows
.into_iter()

View File

@@ -28,7 +28,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
r#"INSERT INTO movie_profiles
@@ -61,13 +61,13 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&enriched_at)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.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 (?,?,?)",
@@ -77,14 +77,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&g.name)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.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 (?,?,?)",
@@ -94,14 +94,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&k.name)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
for c in &p.cast {
sqlx::query(
"INSERT OR IGNORE INTO movie_cast \
@@ -116,14 +116,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
for cr in &p.crew {
sqlx::query(
"INSERT OR IGNORE INTO movie_crew \
@@ -138,10 +138,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
}
tx.commit().await.map_err(adapter_common::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> {
@@ -156,7 +156,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let row = match row {
Some(r) => r,
@@ -174,7 +174,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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,
@@ -186,7 +186,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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,
@@ -201,7 +201,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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,
@@ -222,7 +222,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.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,
@@ -284,7 +284,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&threshold)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows
.into_iter()

View File

@@ -26,7 +26,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(rows
.iter()
@@ -48,7 +48,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
for (i, field) in fields.into_iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string();
@@ -63,7 +63,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(position)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
}
Ok(())

View File

@@ -30,7 +30,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(session.created_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -41,7 +41,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.map(RefreshSessionRow::into_domain).transpose()
}
@@ -51,7 +51,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(token)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -60,7 +60,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -70,7 +70,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(&now)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected())
}
}

View File

@@ -8,7 +8,7 @@ use domain::{
use sqlx::SqlitePool;
use crate::models::ReviewRow;
use adapter_common::datetime_to_str;
use crate::shared::datetime_to_str;
pub struct SqliteReviewRepository {
pool: SqlitePool,
@@ -51,7 +51,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(review.spoilers())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -65,7 +65,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.map(ReviewRow::into_domain)
.transpose()
}
@@ -88,7 +88,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -99,7 +99,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -112,7 +112,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.into_iter()
.map(ReviewRow::into_domain)
.collect()

View File

@@ -0,0 +1,394 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::PersonId,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults,
collections::Paginated,
},
ports::{SearchCommand, SearchPort},
value_objects::MovieId,
};
use sqlx::SqlitePool;
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>,
)
}
#[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(())
}
}
#[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,
})
}
}
#[cfg(test)]
mod tests;

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,198 @@
use domain::{
errors::DomainError,
models::{
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username, WatchlistEntryId,
},
};
pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
pub(crate) fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
pub(crate) fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
pub(crate) fn movie_row_to_domain(
id: String,
external_metadata_id: Option<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&id)?);
let external_metadata_id = external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(title)?;
let release_year = ReleaseYear::new(release_year as u16)?;
let poster_path = poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
director,
poster_path,
))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn review_row_to_domain(
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,
) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
let rating = Rating::new(rating as u8)?;
let comment = comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&watched_at)?;
let created_at = parse_datetime(&created_at)?;
let source = match remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = 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,
}))
}
pub(crate) fn movie_summary_to_domain(
movie: Movie,
genres: Vec<String>,
runtime_minutes: Option<i64>,
original_language: Option<String>,
overview: Option<String>,
collection_name: Option<String>,
) -> MovieSummary {
MovieSummary {
movie,
genres,
runtime_minutes: runtime_minutes.map(|v| v as u32),
original_language,
overview,
collection_name,
}
}
pub(crate) fn movie_stats_to_domain(
total_count: i64,
avg_rating: Option<f64>,
federated_count: i64,
rating_histogram: [i64; 5],
) -> MovieStats {
MovieStats {
total_count: total_count as u64,
avg_rating,
federated_count: federated_count as u64,
rating_histogram: [
rating_histogram[0] as u64,
rating_histogram[1] as u64,
rating_histogram[2] as u64,
rating_histogram[3] as u64,
rating_histogram[4] as u64,
],
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_summary_to_domain(
id: String,
email: String,
username: String,
display_name: Option<String>,
total_movies: i64,
avg_rating: Option<f64>,
avatar_path: Option<String>,
) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&id)?),
Email::new(email)?,
Username::new(username)?,
display_name,
total_movies,
avg_rating,
avatar_path,
))
}
pub(crate) fn watchlist_entry_to_domain(
id: String,
user_id: String,
movie_id: String,
added_at: String,
) -> Result<WatchlistEntry, DomainError> {
Ok(WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
added_at: parse_datetime(&added_at)?,
})
}
pub(crate) fn watchlist_with_movie_to_domain(
entry: WatchlistEntry,
movie: Movie,
) -> WatchlistWithMovie {
WatchlistWithMovie { entry, movie }
}

View File

@@ -0,0 +1,276 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie,
},
ports::LocalApContentQuery,
value_objects::{
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,
},
};
use sqlx::SqlitePool;
pub struct SqliteApContentQuery {
pool: SqlitePool,
}
impl SqliteApContentQuery {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
// ── Local row types ──────────────────────────────────────────────────────────
use crate::shared::{parse_datetime, parse_uuid};
#[derive(sqlx::FromRow)]
struct MovieRow {
id: String,
external_metadata_id: Option<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
}
impl MovieRow {
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)]
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 {
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)]
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 {
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)]
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 {
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 ─────────────────────────────────────────────────
#[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,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,357 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::SqliteSocialRepository;
use crate::shared::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
match status {
"pending" => Some(FollowStatus::Pending),
"accepted" => Some(FollowStatus::Accepted),
"rejected" => Some(FollowStatus::Rejected),
_ => None,
}
}
#[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(())
}
}
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,
}
}
#[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,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,34 @@
mod federated_profile;
mod follow_repository;
mod local_social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository;
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)]
#[path = "tests/follow_relation_tests.rs"]
mod follow_relation_tests;

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

@@ -32,7 +32,7 @@ impl SqliteStatsRepository {
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map_err(crate::shared::map_sqlx_error)
}
async fn fetch_user_favorite_director(
@@ -51,7 +51,7 @@ impl SqliteStatsRepository {
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(row)
}
@@ -70,7 +70,7 @@ impl SqliteStatsRepository {
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(row)
}
}
@@ -86,7 +86,7 @@ impl StatsRepository for SqliteStatsRepository {
self.fetch_user_most_active_month(&uid)
)?;
let most_active_month = most_active.map(|ym| adapter_common::format_year_month(&ym));
let most_active_month = most_active.map(|ym| crate::shared::format_year_month(&ym));
Ok(UserStats {
total_movies: totals.total,
@@ -168,14 +168,14 @@ impl StatsRepository for SqliteStatsRepository {
.bind(&uid)
.fetch_all(&self.pool)
)
.map_err(adapter_common::map_sqlx_error)?;
.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: adapter_common::format_year_month(&r.month),
month_label: crate::shared::format_year_month(&r.month),
year_month: r.month,
avg_rating: r.avg_rating,
count: r.count,

View File

@@ -28,7 +28,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
match row {
Some(r) => {
@@ -63,7 +63,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
})
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
}
@@ -79,7 +79,7 @@ impl UserFederationSettingsQuery for SqliteUserSettingsRepository {
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
match row {
Some(r) => {

View File

@@ -68,7 +68,7 @@ impl UserRepository for SqliteUserRepository {
.bind(email_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
@@ -81,7 +81,7 @@ impl UserRepository for SqliteUserRepository {
.bind(username_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
@@ -121,7 +121,7 @@ impl UserRepository for SqliteUserRepository {
.bind(role)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -132,7 +132,7 @@ impl UserRepository for SqliteUserRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let Some(r) = row else { return Ok(None) };
@@ -189,7 +189,7 @@ impl UserRepository for SqliteUserRepository {
)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.into_iter()
.map(UserSummaryRow::into_domain)
.collect()

View File

@@ -7,7 +7,7 @@ use domain::{
};
use sqlx::{Row, SqlitePool};
use adapter_common::datetime_to_str;
use crate::shared::datetime_to_str;
fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
s.parse()
@@ -60,7 +60,7 @@ impl WatchEventCommand for SqliteWatchEventRepository {
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -78,7 +78,7 @@ impl WatchEventCommand for SqliteWatchEventRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -103,7 +103,7 @@ impl WatchEventCommand for SqliteWatchEventRepository {
let result = q
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected())
}
@@ -117,7 +117,7 @@ impl WatchEventCommand for SqliteWatchEventRepository {
.bind(&before_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
@@ -137,7 +137,7 @@ impl WatchEventQuery for SqliteWatchEventRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
@@ -153,7 +153,7 @@ impl WatchEventQuery for SqliteWatchEventRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref().map(row_to_watch_event).transpose()
}
@@ -176,7 +176,7 @@ impl WatchEventQuery for SqliteWatchEventRepository {
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
@@ -198,7 +198,7 @@ impl WatchEventQuery for SqliteWatchEventRepository {
.bind(&after_str)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
@@ -207,34 +207,32 @@ impl WatchEventQuery for SqliteWatchEventRepository {
}
fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result<WatchEvent, DomainError> {
let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let movie_id_str: Option<&str> = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let title: String = row
.try_get("title")
.map_err(adapter_common::map_sqlx_error)?;
let year: Option<i64> = row
.try_get("year")
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let source_str: String = row
.try_get("source")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let source: WatchEventSource = source_str
.parse()
@@ -297,7 +295,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&last_used)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -310,7 +308,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(hash)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref().map(row_to_webhook_token).transpose()
}
@@ -325,7 +323,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_webhook_token).collect()
}
@@ -339,7 +337,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&uid)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
@@ -356,32 +354,32 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
}
fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result<WebhookToken, DomainError> {
let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let token_hash: String = row
.try_get("token_hash")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let provider_str: String = row
.try_get("provider")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let label: Option<String> = row
.try_get("label")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let last_used_str: Option<String> = row
.try_get("last_used_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let provider: WatchEventSource = provider_str
.parse()

View File

@@ -11,7 +11,7 @@ use domain::{
use sqlx::{Row, SqlitePool};
use crate::models::WatchlistRow;
use adapter_common::datetime_to_str;
use crate::shared::datetime_to_str;
pub struct SqliteWatchlistRepository {
pool: SqlitePool,
@@ -41,7 +41,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&added_at)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -56,7 +56,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
@@ -80,7 +80,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
@@ -108,13 +108,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
@@ -141,7 +141,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map_err(crate::shared::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
Ok(count > 0)

View File

@@ -13,7 +13,7 @@ use domain::{
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use adapter_common::{parse_datetime, parse_uuid};
use crate::shared::{parse_datetime, parse_uuid};
fn status_to_str(s: &WrapUpStatus) -> &'static str {
match s {
@@ -82,7 +82,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&completed)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -102,7 +102,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -121,7 +121,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -137,7 +137,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose()
}
@@ -153,7 +153,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_record).collect()
}
@@ -166,7 +166,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
rows.iter().map(row_to_record).collect()
}
@@ -195,7 +195,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&end_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose()
}
@@ -205,7 +205,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
@@ -219,37 +219,37 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&before_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result<WrapUpRecord, DomainError> {
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let start_date_str: String = row
.try_get("start_date")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let end_date_str: String = row
.try_get("end_date")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let report_json: Option<String> = row
.try_get("report_json")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let error_message: Option<String> = row
.try_get("error_message")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let completed_at_str: Option<String> = row
.try_get("completed_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
@@ -318,7 +318,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if rows.is_empty() {
return Ok(vec![]);
@@ -330,7 +330,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
for row in &rows {
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
if seen.insert(mid.clone()) {
movie_ids.push(mid);
}
@@ -348,40 +348,40 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
for row in &rows {
let movie_id_str: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let title: String = row
.try_get("title")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let release_year: i64 = row
.try_get("release_year")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let director: Option<String> = row
.try_get("director")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let poster_path: Option<String> = row
.try_get("poster_path")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let runtime_minutes: Option<i32> = row
.try_get("runtime_minutes")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let budget_usd: Option<i64> = row
.try_get("budget_usd")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let original_language: Option<String> = row
.try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
.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();
@@ -456,16 +456,14 @@ pub(crate) async fn fetch_genres_sqlite(
let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
.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)
@@ -489,16 +487,14 @@ async fn fetch_keywords_sqlite(
let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
.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)
@@ -525,25 +521,23 @@ async fn fetch_cast_sqlite(
let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
.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(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let tmdb_person_id: i64 = row
.try_get("tmdb_person_id")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
let profile_path: Option<String> = row
.try_get("profile_path")
.map_err(adapter_common::map_sqlx_error)?;
.map_err(crate::shared::map_sqlx_error)?;
map.entry(mid).or_default().push(CastEntry {
name,
billing_order: billing_order as u32,