chore: remove duplicated code for pagination

This commit is contained in:
Jon Seager 2025-11-25 19:56:29 +00:00
parent d41cf7a86c
commit dd0f716437
No known key found for this signature in database
10 changed files with 172 additions and 245 deletions

View file

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

View file

@ -1,3 +1,4 @@
pub mod pagination;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod sessions; pub mod sessions;

View file

@ -0,0 +1,76 @@
use sqlx::{FromRow, query_as, query_scalar};
use crate::domain::RepositoryError;
use crate::domain::listing::{ListRequest, Page, PageSize, SortKey};
use crate::infrastructure::database::{DatabasePool, DatabaseRow};
pub async fn paginate<K, R, T, MapFn>(
pool: &DatabasePool,
request: &ListRequest<K>,
base_query: &str,
count_query: &str,
order_clause: &str,
map_fn: MapFn,
) -> Result<Page<T>, RepositoryError>
where
K: SortKey,
R: for<'r> FromRow<'r, DatabaseRow> + Send + Unpin,
T: Send,
MapFn: Fn(R) -> Result<T, RepositoryError> + Send + Sync,
{
match request.page_size() {
PageSize::All => {
let query = format!("{} ORDER BY {}", base_query, order_clause);
let records = 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 {
items.push(map_fn(record)?);
}
let total = items.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(items, 1, page_size.max(1), total, true))
}
PageSize::Limited(page_size) => {
let limit = page_size as i64;
let mut page = request.page();
let offset = ((page - 1) as i64).saturating_mul(limit);
let query_sql = format!("{} ORDER BY {} LIMIT ? OFFSET ?", base_query, order_clause);
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()))?;
if page > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1);
let offset = ((page - 1) as i64).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()))?;
}
let mut items = Vec::with_capacity(records.len());
for record in records {
items.push(map_fn(record)?);
}
Ok(Page::new(items, page, page_size, total as u64, false))
}
}
}

View file

@ -1,10 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{QueryBuilder, query, query_as, query_scalar}; use sqlx::{QueryBuilder, query, query_as};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::RoasterRepository; use crate::domain::repositories::RoasterRepository;
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::domain::timeline::TimelineEventDetail; use crate::domain::timeline::TimelineEventDetail;
@ -159,77 +159,19 @@ impl RoasterRepository for SqlRoasterRepository {
request: &ListRequest<RoasterSortKey>, request: &ListRequest<RoasterSortKey>,
) -> Result<Page<Roaster>, RepositoryError> { ) -> Result<Page<Roaster>, RepositoryError> {
let order_clause = Self::sort_clause(request); let order_clause = Self::sort_clause(request);
let base_query =
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters";
let count_query = "SELECT COUNT(*) FROM roasters";
match request.page_size() { crate::infrastructure::repositories::pagination::paginate(
PageSize::All => { &self.pool,
let query = format!( request,
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {}", base_query,
order_clause count_query,
); &order_clause,
|record| Ok(Self::into_domain(record)),
let records = query_as::<_, RoasterRecord>(&query) )
.fetch_all(&self.pool) .await
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let items = records
.into_iter()
.map(Self::into_domain)
.collect::<Vec<_>>();
let total = items.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(items, 1, page_size.max(1), total, true))
}
PageSize::Limited(page_size) => {
let limit = page_size as i64;
let mut page_number = request.page();
let offset = ((page_number - 1) as i64).saturating_mul(limit);
let query = format!(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?",
order_clause
);
let mut records = query_as::<_, RoasterRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let total: i64 = query_scalar("SELECT COUNT(*) FROM roasters")
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page_number > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32;
page_number = last_page.max(1);
let offset = ((page_number - 1) as i64).saturating_mul(limit);
records = query_as::<_, RoasterRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let items = records
.into_iter()
.map(Self::into_domain)
.collect::<Vec<_>>();
Ok(Page::new(
items,
page_number,
page_size,
total as u64,
false,
))
}
}
} }
async fn update( async fn update(

View file

@ -5,7 +5,7 @@ use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::RoastRepository; use crate::domain::repositories::RoastRepository;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::TimelineEventDetail; use crate::domain::timeline::TimelineEventDetail;
@ -195,77 +195,18 @@ impl RoastRepository for SqlRoastRepository {
request: &ListRequest<RoastSortKey>, request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError> { ) -> Result<Page<RoastWithRoaster>, RepositoryError> {
let order_clause = Self::order_clause(request); let order_clause = Self::order_clause(request);
let base_query = "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id";
let count_query = "SELECT COUNT(*) FROM roasts";
match request.page_size() { crate::infrastructure::repositories::pagination::paginate(
PageSize::All => { &self.pool,
let query = format!( request,
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n ORDER BY {}", base_query,
order_clause count_query,
); &order_clause,
|record: RoastWithRoasterRecord| record.into_with_roaster(),
let records = query_as::<_, RoastWithRoasterRecord>(&query) )
.fetch_all(&self.pool) .await
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let items = records
.into_iter()
.map(|record| record.into_with_roaster())
.collect::<Result<Vec<_>, _>>()?;
let total = items.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(items, 1, page_size.max(1), total, true))
}
PageSize::Limited(page_size) => {
let limit = page_size as i64;
let mut page_number = request.page();
let offset = ((page_number - 1) as i64).saturating_mul(limit);
let query = format!(
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n ORDER BY {} \n LIMIT ? OFFSET ?",
order_clause
);
let mut records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let total: i64 = query_scalar("SELECT COUNT(*) FROM roasts")
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page_number > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32;
page_number = last_page.max(1);
let offset = ((page_number - 1) as i64).saturating_mul(limit);
records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let items = records
.into_iter()
.map(|record| record.into_with_roaster())
.collect::<Result<Vec<_>, _>>()?;
Ok(Page::new(
items,
page_number,
page_size,
total as u64,
false,
))
}
}
} }
async fn list_by_roaster( async fn list_by_roaster(

View file

@ -1,11 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::from_str; use serde_json::from_str;
use sqlx::{query_as, query_scalar};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::TimelineEventId; use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::TimelineEventRepository; use crate::domain::repositories::TimelineEventRepository;
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey};
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
@ -33,73 +31,19 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
}; };
let order_clause = format!("occurred_at {direction_sql}, id DESC"); let order_clause = format!("occurred_at {direction_sql}, id DESC");
let base_query = "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \
FROM timeline_events";
let count_query = "SELECT COUNT(*) FROM timeline_events";
match request.page_size() { crate::infrastructure::repositories::pagination::paginate(
PageSize::All => { &self.pool,
let query = format!( request,
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ base_query,
FROM timeline_events \ count_query,
ORDER BY {order_clause}" &order_clause,
); |record: TimelineEventRecord| record.into_domain(),
)
let records = query_as::<_, TimelineEventRecord>(&query) .await
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let mut events = Vec::with_capacity(records.len());
for record in records {
events.push(record.into_domain()?);
}
let total = events.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(events, 1, page_size.max(1), total, true))
}
PageSize::Limited(page_size) => {
let limit = page_size as i64;
let mut page = request.page();
let offset = ((page - 1) as i64).saturating_mul(limit);
let query = format!(
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \
FROM timeline_events \
ORDER BY {order_clause} \
LIMIT ? OFFSET ?"
);
let mut records = query_as::<_, TimelineEventRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let total: i64 = query_scalar("SELECT COUNT(*) FROM timeline_events")
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1);
let offset = ((page - 1) as i64).saturating_mul(limit);
records = query_as::<_, TimelineEventRecord>(&query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let mut events = Vec::with_capacity(records.len());
for record in records {
events.push(record.into_domain()?);
}
Ok(Page::new(events, page, page_size, total as u64, false))
}
}
} }
} }

View file

@ -14,7 +14,7 @@ use crate::server::auth::AuthenticatedUser;
use crate::server::errors::{ApiError, AppError, map_app_error}; use crate::server::errors::{ApiError, AppError, map_app_error};
use crate::server::routes::render_html; use crate::server::routes::render_html;
use crate::server::routes::support::{ use crate::server::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
}; };
use crate::server::server::AppState; use crate::server::server::AppState;
@ -31,12 +31,13 @@ async fn load_roaster_page(
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
let normalized_request = crate::server::routes::support::normalize_request(request, &page); Ok(crate::server::routes::support::build_page_view(
let roasters = Paginated::from_page(page, RoasterView::from); page,
let navigator = request,
ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, normalized_request); RoasterView::from,
ROASTER_PAGE_PATH,
Ok((roasters, navigator)) ROASTER_FRAGMENT_PATH,
))
} }
pub(crate) async fn roasters_page( pub(crate) async fn roasters_page(
@ -204,10 +205,5 @@ async fn render_roaster_list_fragment(
navigator, navigator,
}; };
let html = crate::presentation::templates::render_template(template) crate::server::routes::support::render_fragment(template, "#roaster-list")
.map_err(|err| AppError::unexpected(format!("failed to render roaster list: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), "#roaster-list");
Ok(response)
} }

View file

@ -14,7 +14,7 @@ use crate::server::auth::AuthenticatedUser;
use crate::server::errors::{ApiError, AppError, map_app_error}; use crate::server::errors::{ApiError, AppError, map_app_error};
use crate::server::routes::render_html; use crate::server::routes::render_html;
use crate::server::routes::support::{ use crate::server::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
}; };
use crate::server::server::AppState; use crate::server::server::AppState;
@ -31,11 +31,13 @@ async fn load_roast_page(
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
let normalized_request = crate::server::routes::support::normalize_request(request, &page); Ok(crate::server::routes::support::build_page_view(
let roasts = Paginated::from_page(page, RoastView::from_list_item); page,
let navigator = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, normalized_request); request,
RoastView::from_list_item,
Ok((roasts, navigator)) ROAST_PAGE_PATH,
ROAST_FRAGMENT_PATH,
))
} }
pub(crate) async fn roasts_page( pub(crate) async fn roasts_page(
@ -268,10 +270,5 @@ async fn render_roast_list_fragment(
let template = RoastListTemplate { roasts, navigator }; let template = RoastListTemplate { roasts, navigator };
let html = crate::presentation::templates::render_template(template) crate::server::routes::support::render_fragment(template, "#roast-list")
.map_err(|err| AppError::unexpected(format!("failed to render roast list: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), "#roast-list");
Ok(response)
} }

View file

@ -1,11 +1,14 @@
use axum::async_trait; use axum::async_trait;
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
use axum::response::{Html, IntoResponse, Response};
use askama::Template;
use serde::Deserialize; use serde::Deserialize;
use crate::domain::listing::{ use crate::domain::listing::{
DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey, DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey,
}; };
use crate::presentation::views::{ListNavigator, Paginated};
use crate::server::errors::{ApiError, AppError}; use crate::server::errors::{ApiError, AppError};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -90,6 +93,34 @@ where
) )
} }
pub fn build_page_view<K, T, V>(
page: Page<T>,
request: ListRequest<K>,
view_mapper: impl FnMut(T) -> V,
base_path: &'static str,
fragment_path: &'static str,
) -> (Paginated<V>, ListNavigator<K>)
where
K: SortKey,
{
let normalized_request = normalize_request(request, &page);
let view_page = Paginated::from_page(page, view_mapper);
let navigator = ListNavigator::new(base_path, fragment_path, normalized_request);
(view_page, navigator)
}
pub fn render_fragment<T: Template>(
template: T,
selector: &'static str,
) -> Result<Response, AppError> {
let html = crate::presentation::templates::render_template(template)
.map_err(|err| AppError::unexpected(format!("failed to render fragment: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), selector);
Ok(response)
}
fn page_size_from_text(value: &str) -> PageSize { fn page_size_from_text(value: &str) -> PageSize {
if value.eq_ignore_ascii_case("all") { if value.eq_ignore_ascii_case("all") {
PageSize::All PageSize::All

View file

@ -1,7 +1,7 @@
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use crate::domain::listing::ListRequest; use crate::domain::listing::ListRequest;
use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
@ -10,7 +10,7 @@ use crate::presentation::views::{ListNavigator, Paginated, TimelineEventView, Ti
use crate::server::errors::{AppError, map_app_error}; use crate::server::errors::{AppError, map_app_error};
use crate::server::routes::render_html; use crate::server::routes::render_html;
use crate::server::routes::support::{ use crate::server::routes::support::{
ListQuery, is_datastar_request, normalize_request, set_datastar_patch_headers, ListQuery, is_datastar_request, normalize_request,
}; };
use crate::server::server::AppState; use crate::server::server::AppState;
@ -65,12 +65,7 @@ async fn render_timeline_chunk(
months: data.months, months: data.months,
}; };
let html = crate::presentation::templates::render_template(template) crate::server::routes::support::render_fragment(template, "#timeline-loader")
.map_err(|err| AppError::unexpected(format!("failed to render timeline chunk: {err}")))?;
let mut response = Html(html).into_response();
set_datastar_patch_headers(response.headers_mut(), "#timeline-loader");
Ok(response)
} }
struct TimelinePageData { struct TimelinePageData {