From dd0f716437b12bf68b32a013f83aebeaec4b35be Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 25 Nov 2025 19:56:29 +0000 Subject: [PATCH] chore: remove duplicated code for pagination --- src/infrastructure/database.rs | 4 + src/infrastructure/repositories/mod.rs | 1 + src/infrastructure/repositories/pagination.rs | 76 ++++++++++++++++ src/infrastructure/repositories/roasters.rs | 86 +++---------------- src/infrastructure/repositories/roasts.rs | 83 +++--------------- .../repositories/timeline_events.rs | 82 +++--------------- src/server/routes/roasters.rs | 22 ++--- src/server/routes/roasts.rs | 21 ++--- src/server/routes/support.rs | 31 +++++++ src/server/routes/timeline.rs | 11 +-- 10 files changed, 172 insertions(+), 245 deletions(-) create mode 100644 src/infrastructure/repositories/pagination.rs diff --git a/src/infrastructure/database.rs b/src/infrastructure/database.rs index 711e0b0..be6f911 100644 --- a/src/infrastructure/database.rs +++ b/src/infrastructure/database.rs @@ -13,6 +13,8 @@ pub type DatabasePool = sqlx::SqlitePool; type PoolOptions = sqlx::sqlite::SqlitePoolOptions; #[cfg(feature = "sqlite")] pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>; +#[cfg(feature = "sqlite")] +pub type DatabaseRow = sqlx::sqlite::SqliteRow; #[cfg(feature = "postgres")] pub type DatabasePool = sqlx::PgPool; @@ -20,6 +22,8 @@ pub type DatabasePool = sqlx::PgPool; type PoolOptions = sqlx::postgres::PgPoolOptions; #[cfg(feature = "postgres")] pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>; +#[cfg(feature = "postgres")] +pub type DatabaseRow = sqlx::postgres::PgRow; pub struct Database { pool: DatabasePool, diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index e502d78..de97f82 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,3 +1,4 @@ +pub mod pagination; pub mod roasters; pub mod roasts; pub mod sessions; diff --git a/src/infrastructure/repositories/pagination.rs b/src/infrastructure/repositories/pagination.rs new file mode 100644 index 0000000..9e15802 --- /dev/null +++ b/src/infrastructure/repositories/pagination.rs @@ -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( + pool: &DatabasePool, + request: &ListRequest, + base_query: &str, + count_query: &str, + order_clause: &str, + map_fn: MapFn, +) -> Result, RepositoryError> +where + K: SortKey, + R: for<'r> FromRow<'r, DatabaseRow> + Send + Unpin, + T: Send, + MapFn: Fn(R) -> Result + 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)) + } + } +} diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index cfd4d69..a24a1b5 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -1,10 +1,10 @@ use async_trait::async_trait; 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::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::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::timeline::TimelineEventDetail; @@ -159,77 +159,19 @@ impl RoasterRepository for SqlRoasterRepository { request: &ListRequest, ) -> Result, RepositoryError> { 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() { - PageSize::All => { - let query = format!( - "SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {}", - order_clause - ); - - let records = query_as::<_, RoasterRecord>(&query) - .fetch_all(&self.pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - - let items = records - .into_iter() - .map(Self::into_domain) - .collect::>(); - 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::>(); - - Ok(Page::new( - items, - page_number, - page_size, - total as u64, - false, - )) - } - } + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + base_query, + count_query, + &order_clause, + |record| Ok(Self::into_domain(record)), + ) + .await } async fn update( diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index 92b220c..f1b1e93 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -5,7 +5,7 @@ use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar}; use crate::domain::RepositoryError; 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::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::timeline::TimelineEventDetail; @@ -195,77 +195,18 @@ impl RoastRepository for SqlRoastRepository { request: &ListRequest, ) -> Result, RepositoryError> { 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() { - PageSize::All => { - 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 {}", - order_clause - ); - - let records = query_as::<_, RoastWithRoasterRecord>(&query) - .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::, _>>()?; - - 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::, _>>()?; - - Ok(Page::new( - items, - page_number, - page_size, - total as u64, - false, - )) - } - } + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + base_query, + count_query, + &order_clause, + |record: RoastWithRoasterRecord| record.into_with_roaster(), + ) + .await } async fn list_by_roaster( diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index f4ecb20..18b5385 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -1,11 +1,9 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde_json::from_str; -use sqlx::{query_as, query_scalar}; - use crate::domain::RepositoryError; 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::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::infrastructure::database::DatabasePool; @@ -33,73 +31,19 @@ impl TimelineEventRepository for SqlTimelineEventRepository { }; 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() { - PageSize::All => { - let query = format!( - "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ - FROM timeline_events \ - ORDER BY {order_clause}" - ); - - let records = query_as::<_, TimelineEventRecord>(&query) - .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)) - } - } + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + base_query, + count_query, + &order_clause, + |record: TimelineEventRecord| record.into_domain(), + ) + .await } } diff --git a/src/server/routes/roasters.rs b/src/server/routes/roasters.rs index d717aca..5c5026d 100644 --- a/src/server/routes/roasters.rs +++ b/src/server/routes/roasters.rs @@ -14,7 +14,7 @@ use crate::server::auth::AuthenticatedUser; use crate::server::errors::{ApiError, AppError, map_app_error}; use crate::server::routes::render_html; 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; @@ -31,12 +31,13 @@ async fn load_roaster_page( .await .map_err(AppError::from)?; - let normalized_request = crate::server::routes::support::normalize_request(request, &page); - let roasters = Paginated::from_page(page, RoasterView::from); - let navigator = - ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, normalized_request); - - Ok((roasters, navigator)) + Ok(crate::server::routes::support::build_page_view( + page, + request, + RoasterView::from, + ROASTER_PAGE_PATH, + ROASTER_FRAGMENT_PATH, + )) } pub(crate) async fn roasters_page( @@ -204,10 +205,5 @@ async fn render_roaster_list_fragment( navigator, }; - let html = crate::presentation::templates::render_template(template) - .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) + crate::server::routes::support::render_fragment(template, "#roaster-list") } diff --git a/src/server/routes/roasts.rs b/src/server/routes/roasts.rs index 5b96d75..2c81e39 100644 --- a/src/server/routes/roasts.rs +++ b/src/server/routes/roasts.rs @@ -14,7 +14,7 @@ use crate::server::auth::AuthenticatedUser; use crate::server::errors::{ApiError, AppError, map_app_error}; use crate::server::routes::render_html; 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; @@ -31,11 +31,13 @@ async fn load_roast_page( .await .map_err(AppError::from)?; - let normalized_request = crate::server::routes::support::normalize_request(request, &page); - let roasts = Paginated::from_page(page, RoastView::from_list_item); - let navigator = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, normalized_request); - - Ok((roasts, navigator)) + Ok(crate::server::routes::support::build_page_view( + page, + request, + RoastView::from_list_item, + ROAST_PAGE_PATH, + ROAST_FRAGMENT_PATH, + )) } pub(crate) async fn roasts_page( @@ -268,10 +270,5 @@ async fn render_roast_list_fragment( let template = RoastListTemplate { roasts, navigator }; - let html = crate::presentation::templates::render_template(template) - .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) + crate::server::routes::support::render_fragment(template, "#roast-list") } diff --git a/src/server/routes/support.rs b/src/server/routes/support.rs index b6c8095..9870b64 100644 --- a/src/server/routes/support.rs +++ b/src/server/routes/support.rs @@ -1,11 +1,14 @@ use axum::async_trait; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; +use axum::response::{Html, IntoResponse, Response}; +use askama::Template; use serde::Deserialize; use crate::domain::listing::{ DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey, }; +use crate::presentation::views::{ListNavigator, Paginated}; use crate::server::errors::{ApiError, AppError}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -90,6 +93,34 @@ where ) } +pub fn build_page_view( + page: Page, + request: ListRequest, + view_mapper: impl FnMut(T) -> V, + base_path: &'static str, + fragment_path: &'static str, +) -> (Paginated, ListNavigator) +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( + template: T, + selector: &'static str, +) -> Result { + 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 { if value.eq_ignore_ascii_case("all") { PageSize::All diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs index 08b2a0b..a1ab1f5 100644 --- a/src/server/routes/timeline.rs +++ b/src/server/routes/timeline.rs @@ -1,7 +1,7 @@ use axum::extract::{Query, State}; use axum::http::HeaderMap; use axum::http::StatusCode; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{IntoResponse, Response}; use crate::domain::listing::ListRequest; 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::routes::render_html; 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; @@ -65,12 +65,7 @@ async fn render_timeline_chunk( months: data.months, }; - let html = crate::presentation::templates::render_template(template) - .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) + crate::server::routes::support::render_fragment(template, "#timeline-loader") } struct TimelinePageData {