feat(search): add SearchFilter and DatabaseDriver to pagination infrastructure

Add SearchFilter struct for LIKE-based search with dynamic column
matching. Add DatabaseDriver type alias to support QueryBuilder
generics across SQLite and Postgres feature flags.
This commit is contained in:
Jon Seager 2026-02-03 12:16:37 +00:00
parent ff8f0c781e
commit 18d39d13ef
No known key found for this signature in database
2 changed files with 123 additions and 25 deletions

View file

@ -15,6 +15,8 @@ type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>; pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
pub type DatabaseRow = sqlx::sqlite::SqliteRow; pub type DatabaseRow = sqlx::sqlite::SqliteRow;
#[cfg(feature = "sqlite")]
pub type DatabaseDriver = sqlx::Sqlite;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
pub type DatabasePool = sqlx::PgPool; pub type DatabasePool = sqlx::PgPool;
@ -24,6 +26,8 @@ type PoolOptions = sqlx::postgres::PgPoolOptions;
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>; pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
pub type DatabaseRow = sqlx::postgres::PgRow; pub type DatabaseRow = sqlx::postgres::PgRow;
#[cfg(feature = "postgres")]
pub type DatabaseDriver = sqlx::Postgres;
pub struct Database { pub struct Database {
pool: DatabasePool, pool: DatabasePool,

View file

@ -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::RepositoryError;
use crate::domain::listing::{ListRequest, Page, PageSize, SortKey}; 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<Self> {
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<K, R, T, MapFn>( pub async fn paginate<K, R, T, MapFn>(
pool: &DatabasePool, pool: &DatabasePool,
@ -10,6 +31,7 @@ pub async fn paginate<K, R, T, MapFn>(
base_query: &str, base_query: &str,
count_query: &str, count_query: &str,
order_clause: &str, order_clause: &str,
search: Option<&SearchFilter>,
map_fn: MapFn, map_fn: MapFn,
) -> Result<Page<T>, RepositoryError> ) -> Result<Page<T>, RepositoryError>
where where
@ -20,11 +42,22 @@ where
{ {
match request.page_size() { match request.page_size() {
PageSize::All => { PageSize::All => {
let query = format!("{base_query} ORDER BY {order_clause}"); let records: Vec<R> = if let Some(sf) = search {
let records = query_as::<_, R>(&query) let mut qb = QueryBuilder::new(base_query);
.fetch_all(pool) append_search_condition(&mut qb, base_query, sf);
.await qb.push(" ORDER BY ");
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; 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()); let mut items = Vec::with_capacity(records.len());
for record in records { for record in records {
@ -39,30 +72,31 @@ where
let mut page = request.page(); let mut page = request.page();
let offset = i64::from(page - 1).saturating_mul(limit); 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) let mut records =
.bind(limit) fetch_page::<R>(pool, base_query, order_clause, search, limit, offset).await?;
.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()))?;
if page > 1 && records.is_empty() && total > 0 { if page > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32; let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1); page = last_page.max(1);
let offset = i64::from(page - 1).saturating_mul(limit); let offset = i64::from(page - 1).saturating_mul(limit);
records = query_as::<_, R>(&query_sql) records =
.bind(limit) fetch_page::<R>(pool, base_query, order_clause, search, limit, offset).await?;
.bind(offset)
.fetch_all(pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
} }
let mut items = Vec::with_capacity(records.len()); let mut items = Vec::with_capacity(records.len());
@ -74,3 +108,63 @@ where
} }
} }
} }
async fn fetch_page<R>(
pool: &DatabasePool,
base_query: &str,
order_clause: &str,
search: Option<&SearchFilter>,
limit: i64,
offset: i64,
) -> Result<Vec<R>, 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(")");
}