refactor: merge sqlite adapter family into one crate
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
250
crates/adapters/sqlite/src/event_queue/mod.rs
Normal file
250
crates/adapters/sqlite/src/event_queue/mod.rs
Normal 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)
|
||||
}
|
||||
1
crates/adapters/sqlite/src/event_queue/payload.rs
Normal file
1
crates/adapters/sqlite/src/event_queue/payload.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use event_payload::EventPayload as DbEventPayload;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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 _,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
394
crates/adapters/sqlite/src/search.rs
Normal file
394
crates/adapters/sqlite/src/search.rs
Normal 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;
|
||||
213
crates/adapters/sqlite/src/search/tests.rs
Normal file
213
crates/adapters/sqlite/src/search/tests.rs
Normal 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());
|
||||
}
|
||||
198
crates/adapters/sqlite/src/shared.rs
Normal file
198
crates/adapters/sqlite/src/shared.rs
Normal 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 }
|
||||
}
|
||||
276
crates/adapters/sqlite/src/social/ap_content.rs
Normal file
276
crates/adapters/sqlite/src/social/ap_content.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
58
crates/adapters/sqlite/src/social/federated_profile.rs
Normal file
58
crates/adapters/sqlite/src/social/federated_profile.rs
Normal 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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
357
crates/adapters/sqlite/src/social/follow_repository.rs
Normal file
357
crates/adapters/sqlite/src/social/follow_repository.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
27
crates/adapters/sqlite/src/social/local_social.rs
Normal file
27
crates/adapters/sqlite/src/social/local_social.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
34
crates/adapters/sqlite/src/social/mod.rs
Normal file
34
crates/adapters/sqlite/src/social/mod.rs
Normal 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;
|
||||
109
crates/adapters/sqlite/src/social/remote_goals.rs
Normal file
109
crates/adapters/sqlite/src/social/remote_goals.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
253
crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs
Normal file
253
crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs
Normal 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");
|
||||
}
|
||||
100
crates/adapters/sqlite/src/social/watchlist.rs
Normal file
100
crates/adapters/sqlite/src/social/watchlist.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user