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>;
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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(")");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue