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>;
#[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,

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::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>(
pool: &DatabasePool,
@ -10,6 +31,7 @@ pub async fn paginate<K, R, T, MapFn>(
base_query: &str,
count_query: &str,
order_clause: &str,
search: Option<&SearchFilter>,
map_fn: MapFn,
) -> Result<Page<T>, 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<R> = 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::<R>(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::<R>(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<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(")");
}