diff --git a/src/infrastructure/database.rs b/src/infrastructure/database.rs index be6f911..6718b4d 100644 --- a/src/infrastructure/database.rs +++ b/src/infrastructure/database.rs @@ -15,6 +15,8 @@ type PoolOptions = sqlx::sqlite::SqlitePoolOptions; pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>; #[cfg(feature = "sqlite")] pub type DatabaseRow = sqlx::sqlite::SqliteRow; +#[cfg(feature = "sqlite")] +pub type DatabaseDriver = sqlx::Sqlite; #[cfg(feature = "postgres")] pub type DatabasePool = sqlx::PgPool; @@ -24,6 +26,8 @@ type PoolOptions = sqlx::postgres::PgPoolOptions; pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>; #[cfg(feature = "postgres")] pub type DatabaseRow = sqlx::postgres::PgRow; +#[cfg(feature = "postgres")] +pub type DatabaseDriver = sqlx::Postgres; pub struct Database { pool: DatabasePool, diff --git a/src/infrastructure/repositories/pagination.rs b/src/infrastructure/repositories/pagination.rs index e15d5f3..c1751de 100644 --- a/src/infrastructure/repositories/pagination.rs +++ b/src/infrastructure/repositories/pagination.rs @@ -1,8 +1,29 @@ -use sqlx::{FromRow, query_as, query_scalar}; +use sqlx::{FromRow, QueryBuilder, query_as, query_scalar}; use crate::domain::RepositoryError; use crate::domain::listing::{ListRequest, Page, PageSize, SortKey}; -use crate::infrastructure::database::{DatabasePool, DatabaseRow}; +use crate::infrastructure::database::{DatabaseDriver, DatabasePool, DatabaseRow}; + +/// Describes which columns to search and the term to match. +pub struct SearchFilter { + pub term: String, + pub columns: Vec<&'static str>, +} + +impl SearchFilter { + pub fn new(term: &str, columns: Vec<&'static str>) -> Option { + let term = term.trim().to_lowercase(); + if term.is_empty() { + None + } else { + Some(Self { term, columns }) + } + } + + fn like_pattern(&self) -> String { + format!("%{}%", self.term) + } +} pub async fn paginate( pool: &DatabasePool, @@ -10,6 +31,7 @@ pub async fn paginate( base_query: &str, count_query: &str, order_clause: &str, + search: Option<&SearchFilter>, map_fn: MapFn, ) -> Result, RepositoryError> where @@ -20,11 +42,22 @@ where { match request.page_size() { PageSize::All => { - let query = format!("{base_query} ORDER BY {order_clause}"); - let records = query_as::<_, R>(&query) - .fetch_all(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + let records: Vec = if let Some(sf) = search { + let mut qb = QueryBuilder::new(base_query); + append_search_condition(&mut qb, base_query, sf); + qb.push(" ORDER BY "); + qb.push(order_clause); + qb.build_query_as() + .fetch_all(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + } else { + let query = format!("{base_query} ORDER BY {order_clause}"); + query_as::<_, R>(&query) + .fetch_all(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + }; let mut items = Vec::with_capacity(records.len()); for record in records { @@ -39,30 +72,31 @@ where let mut page = request.page(); let offset = i64::from(page - 1).saturating_mul(limit); - let query_sql = format!("{base_query} ORDER BY {order_clause} LIMIT ? OFFSET ?"); + let total: i64 = if let Some(sf) = search { + let mut count_qb = QueryBuilder::new(count_query); + append_search_condition(&mut count_qb, count_query, sf); + let row: (i64,) = count_qb + .build_query_as() + .fetch_one(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + row.0 + } else { + query_scalar(count_query) + .fetch_one(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + }; - let mut records = query_as::<_, R>(&query_sql) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - - let total: i64 = query_scalar(count_query) - .fetch_one(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + let mut records = + fetch_page::(pool, base_query, order_clause, search, limit, offset).await?; if page > 1 && records.is_empty() && total > 0 { let last_page = ((total + limit - 1) / limit) as u32; page = last_page.max(1); let offset = i64::from(page - 1).saturating_mul(limit); - records = query_as::<_, R>(&query_sql) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + records = + fetch_page::(pool, base_query, order_clause, search, limit, offset).await?; } let mut items = Vec::with_capacity(records.len()); @@ -74,3 +108,63 @@ where } } } + +async fn fetch_page( + pool: &DatabasePool, + base_query: &str, + order_clause: &str, + search: Option<&SearchFilter>, + limit: i64, + offset: i64, +) -> Result, RepositoryError> +where + R: for<'r> FromRow<'r, DatabaseRow> + Send + Unpin, +{ + if let Some(sf) = search { + let mut qb = QueryBuilder::new(base_query); + append_search_condition(&mut qb, base_query, sf); + qb.push(" ORDER BY "); + qb.push(order_clause); + qb.push(" LIMIT "); + qb.push_bind(limit); + qb.push(" OFFSET "); + qb.push_bind(offset); + qb.build_query_as() + .fetch_all(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + } else { + let query_sql = format!("{base_query} ORDER BY {order_clause} LIMIT ? OFFSET ?"); + query_as::<_, R>(&query_sql) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + } +} + +fn append_search_condition( + qb: &mut QueryBuilder<'_, DatabaseDriver>, + base_sql: &str, + search: &SearchFilter, +) { + let connector = if base_sql.to_uppercase().contains("WHERE") { + " AND " + } else { + " WHERE " + }; + qb.push(connector); + qb.push("("); + let pattern = search.like_pattern(); + for (i, col) in search.columns.iter().enumerate() { + if i > 0 { + qb.push(" OR "); + } + qb.push("LOWER("); + qb.push(*col); + qb.push(") LIKE "); + qb.push_bind(pattern.clone()); + } + qb.push(")"); +}