feat: extensible search engine with person entities (FTS5/tsvector)

This commit is contained in:
2026-05-12 18:45:24 +02:00
parent 763d622601
commit c6770659c5
45 changed files with 2421 additions and 86 deletions

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL UNIQUE,
tmdb_person_id BIGINT UNIQUE,
name TEXT NOT NULL,
known_for_department TEXT,
profile_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_persons_external ON persons (external_id);
CREATE INDEX IF NOT EXISTS idx_persons_tmdb_id ON persons (tmdb_person_id);
-- tsvector-based search for movies (equivalent of SQLite FTS5)
CREATE TABLE IF NOT EXISTS movies_search (
movie_id TEXT PRIMARY KEY REFERENCES movies(id) ON DELETE CASCADE,
fts TSVECTOR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_movies_search_fts ON movies_search USING GIN(fts);
-- tsvector-based search for people
CREATE TABLE IF NOT EXISTS people_search (
person_id TEXT PRIMARY KEY REFERENCES persons(id) ON DELETE CASCADE,
fts TSVECTOR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_people_search_fts ON people_search USING GIN(fts);

View File

@@ -16,6 +16,7 @@ mod image_ref;
mod import_profile;
mod import_session;
mod models;
mod persons;
mod profile;
mod users;
@@ -27,6 +28,7 @@ use models::{
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
pub use import_profile::PostgresImportProfileRepository;
pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use users::PostgresUserRepository;

View File

@@ -0,0 +1,198 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
use sqlx::PgPool;
use std::sync::Arc;
pub struct PostgresPersonAdapter {
pool: PgPool,
}
impl PostgresPersonAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(PostgresPersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl PersonCommand for PostgresPersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
for person in persons {
let tmdb_id = person.external_id().tmdb_id();
sqlx::query(
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET
external_id = EXCLUDED.external_id,
tmdb_person_id = EXCLUDED.tmdb_person_id,
name = EXCLUDED.name,
known_for_department = EXCLUDED.known_for_department,
profile_path = EXCLUDED.profile_path",
)
.bind(person.id().value().to_string())
.bind(person.external_id().value())
.bind(tmdb_id)
.bind(person.name())
.bind(person.known_for_department())
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(map_err)?;
}
Ok(())
}
}
#[async_trait]
impl PersonQuery for PostgresPersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
}
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = $1",
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let person = self.get_by_id(id).await?.ok_or_else(|| {
DomainError::NotFound(format!("Person {} not found", id.value()))
})?;
let tmdb_id: Option<i64> = sqlx::query_scalar(
"SELECT tmdb_person_id FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
};
#[derive(sqlx::FromRow)]
struct CastRow {
id: String,
title: String,
release_year: Option<i32>,
character: String,
poster_path: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CrewRow {
id: String,
title: String,
release_year: Option<i32>,
job: String,
department: String,
poster_path: Option<String>,
}
let cast = sqlx::query_as::<_, CastRow>(
"SELECT m.id, m.title, m.release_year, mc.character, m.poster_path
FROM movie_cast mc JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = $1 ORDER BY mc.billing_order",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CastCredit {
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),
character: r.character,
poster_path: r.poster_path,
})
.collect();
let crew = sqlx::query_as::<_, CrewRow>(
"SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path
FROM movie_crew mc JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = $1 ORDER BY m.title",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CrewCredit {
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),
job: r.job,
department: r.department,
poster_path: r.poster_path,
})
.collect();
Ok(PersonCredits { person, cast, crew })
}
}