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:
parent
ff8f0c781e
commit
18d39d13ef
2 changed files with 123 additions and 25 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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()))?;
|
||||
.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 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)
|
||||
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 =
|
||||
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(")");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue