From ccb9f09d4a7820cbd68c8faabc022f8cd086d7ea Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 15 Nov 2025 18:06:09 +0100 Subject: [PATCH] feat: Implement pagination for user media retrieval and update related structures --- libertas_api/src/handlers/media_handlers.rs | 10 +-- libertas_api/src/schema.rs | 27 ++++++++ libertas_api/src/services/media_service.rs | 12 +++- libertas_core/src/repositories.rs | 2 +- libertas_core/src/schema.rs | 34 ++++++++++ libertas_core/src/services.rs | 4 +- libertas_infra/src/query_builder.rs | 36 +++++----- .../src/repositories/media_repository.rs | 66 ++++++++++++++----- 8 files changed, 144 insertions(+), 47 deletions(-) diff --git a/libertas_api/src/handlers/media_handlers.rs b/libertas_api/src/handlers/media_handlers.rs index cb1819b..1650f2e 100644 --- a/libertas_api/src/handlers/media_handlers.rs +++ b/libertas_api/src/handlers/media_handlers.rs @@ -17,7 +17,7 @@ use crate::{ error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::{OptionalUserId, UserId}, - schema::{MediaDetailsResponse, MediaResponse}, + schema::{MediaDetailsResponse, MediaResponse, PaginatedResponse, map_paginated_response}, state::AppState, }; @@ -138,12 +138,12 @@ async fn list_user_media( State(state): State, UserId(user_id): UserId, ApiListMediaOptions(options): ApiListMediaOptions, -) -> Result>, ApiError> { - let media_list = state +) -> Result>, ApiError> { + let core_paginated_result = state .media_service .list_user_media(user_id, options) .await?; - let response = media_list.into_iter().map(MediaResponse::from).collect(); - Ok(Json(response)) + let api_response = map_paginated_response(core_paginated_result); + Ok(Json(api_response)) } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 1edd940..08af345 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -2,6 +2,7 @@ use libertas_core::models::{ Album, AlbumPermission, FaceRegion, Media, MediaBundle, MediaMetadata, Person, PersonPermission, Tag, }; +use libertas_core::schema::PaginatedResponse as CorePaginatedResponse; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -246,3 +247,29 @@ pub struct PublicAlbumBundleResponse { pub struct MergePersonRequest { pub source_person_id: Uuid, } + +#[derive(Serialize)] +pub struct PaginatedResponse { + pub data: Vec, + pub page: u32, + pub limit: u32, + pub total_items: i64, + pub total_pages: u32, + pub has_next_page: bool, + pub has_prev_page: bool, +} + +pub fn map_paginated_response(core_response: CorePaginatedResponse) -> PaginatedResponse +where + U: From, +{ + PaginatedResponse { + data: core_response.data.into_iter().map(U::from).collect(), + page: core_response.page, + limit: core_response.limit, + total_items: core_response.total_items, + total_pages: core_response.total_pages, + has_next_page: core_response.has_next_page, + has_prev_page: core_response.has_prev_page, + } +} diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index d40a57b..201ffff 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -12,7 +12,7 @@ use libertas_core::{ media_utils::{extract_exif_data_from_bytes, get_storage_path_and_date}, models::{Media, MediaBundle}, repositories::{MediaMetadataRepository, MediaRepository, UserRepository}, - schema::{ListMediaOptions, UploadMediaData}, + schema::{ListMediaOptions, PaginatedResponse, UploadMediaData}, services::{AuthorizationService, MediaService}, }; use serde_json::json; @@ -109,8 +109,14 @@ impl MediaService for MediaServiceImpl { &self, user_id: Uuid, options: ListMediaOptions, - ) -> CoreResult> { - self.repo.list_by_user(user_id, &options).await + ) -> CoreResult> { + let (data, total_items) = self.repo.list_by_user(user_id, &options).await?; + + let pagination = options.pagination.unwrap(); + + let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items); + + Ok(response) } async fn get_media_filepath(&self, id: Uuid, user_id: Option) -> CoreResult { diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 366f3e1..4280e8e 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -19,7 +19,7 @@ pub trait MediaRepository: Send + Sync { &self, user_id: Uuid, options: &ListMediaOptions, - ) -> CoreResult>; + ) -> CoreResult<(Vec, i64)>; async fn update_thumbnail_path(&self, id: Uuid, thumbnail_path: String) -> CoreResult<()>; async fn delete(&self, id: Uuid) -> CoreResult<()>; } diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index dff75b4..81eab94 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -87,3 +87,37 @@ pub struct MediaImportBundle { pub metadata_models: Vec, pub file_size: i64, } + +#[derive(Debug, Clone)] +pub struct PaginatedResponse { + pub data: Vec, + pub page: u32, + pub limit: u32, + pub total_items: i64, + pub total_pages: u32, + pub has_next_page: bool, + pub has_prev_page: bool, +} + +impl PaginatedResponse { + pub fn new(data: Vec, page: u32, limit: u32, total_items: i64) -> Self { + let total_pages = if limit == 0 { + 0 + } else { + (total_items as f64 / limit as f64).ceil() as u32 + }; + + let has_next_page = page < total_pages; + let has_prev_page = page > 1; + + Self { + data, + page, + limit, + total_items, + total_pages, + has_next_page, + has_prev_page, + } + } +} diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index ddb6806..5595528 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -10,7 +10,7 @@ use crate::{ }, schema::{ AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, - ShareAlbumData, UpdateAlbumData, UploadMediaData, + PaginatedResponse, ShareAlbumData, UpdateAlbumData, UploadMediaData, }, }; @@ -22,7 +22,7 @@ pub trait MediaService: Send + Sync { &self, user_id: Uuid, options: ListMediaOptions, - ) -> CoreResult>; + ) -> CoreResult>; async fn get_media_filepath(&self, id: Uuid, user_id: Option) -> CoreResult; async fn get_media_thumbnail_path(&self, id: Uuid, user_id: Option) -> CoreResult; diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs index 02c6923..0f08bc6 100644 --- a/libertas_infra/src/query_builder.rs +++ b/libertas_infra/src/query_builder.rs @@ -33,16 +33,13 @@ impl MediaQueryBuilder { ))) } } -} -impl QueryBuilder for MediaQueryBuilder { - fn apply_options_to_query<'a>( + pub fn apply_filters_to_query<'a>( &self, mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, options: &'a ListMediaOptions, - ) -> CoreResult> { + ) -> CoreResult<(SqlxQueryBuilder<'a, sqlx::Postgres>, i64)> { let mut metadata_filter_count = 0; - if let Some(filter) = &options.filter { if let Some(mime) = &filter.mime_type { query.push(" AND media.mime_type = "); @@ -51,8 +48,7 @@ impl QueryBuilder for MediaQueryBuilder { if let Some(metadata_filters) = &filter.metadata_filters { if !metadata_filters.is_empty() { - metadata_filter_count = metadata_filters.len(); - + metadata_filter_count = metadata_filters.len() as i64; query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); query.push(" AND ( "); @@ -60,7 +56,6 @@ impl QueryBuilder for MediaQueryBuilder { if i > 0 { query.push(" OR "); } - query.push(" ( mm.tag_name = "); query.push_bind(&filter.tag_name); query.push(" AND mm.tag_value = "); @@ -71,34 +66,38 @@ impl QueryBuilder for MediaQueryBuilder { } } } + Ok((query, metadata_filter_count)) + } - if metadata_filter_count > 0 { - query.push(" GROUP BY media.id "); - query.push(" HAVING COUNT(DISTINCT mm.tag_name) = "); - query.push_bind(metadata_filter_count as i64); - } - + pub fn apply_sorting_to_query<'a>( + &self, + mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a ListMediaOptions, + ) -> CoreResult> { if let Some(sort) = &options.sort { let column = self.validate_sort_column(&sort.sort_by)?; - let direction = match sort.sort_order { SortOrder::Asc => "ASC", SortOrder::Desc => "DESC", }; - let nulls_order = if direction == "ASC" { "NULLS LAST" } else { "NULLS FIRST" }; - let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order); query.push(order_by_clause); } else { query.push(" ORDER BY media.created_at DESC NULLS LAST "); } + Ok(query) + } - // --- 3. Apply Pagination (Future-Proofing Stub) --- + pub fn apply_pagination_to_query<'a>( + &self, + mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a ListMediaOptions, + ) -> CoreResult> { if let Some(pagination) = &options.pagination { let limit = pagination.limit as i64; let offset = (pagination.page.saturating_sub(1) as i64) * limit; @@ -108,7 +107,6 @@ impl QueryBuilder for MediaQueryBuilder { query.push(" OFFSET "); query.push_bind(offset); } - Ok(query) } } diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index b8e4c9e..d52b4e1 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -11,10 +11,7 @@ use libertas_core::{ use sqlx::PgPool; use uuid::Uuid; -use crate::{ - db_models::PostgresMedia, - query_builder::{MediaQueryBuilder, QueryBuilder}, -}; +use crate::{db_models::PostgresMedia, query_builder::MediaQueryBuilder}; #[derive(Clone)] pub struct PostgresMediaRepository { @@ -106,28 +103,63 @@ impl MediaRepository for PostgresMediaRepository { &self, user_id: Uuid, options: &ListMediaOptions, - ) -> CoreResult> { - let mut query = sqlx::QueryBuilder::new( - r#" - SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, - media.thumbnail_path - FROM media - WHERE media.owner_id = - "#, - ); + ) -> CoreResult<(Vec, i64)> { + let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media"; + let mut count_query = sqlx::QueryBuilder::new(count_base_sql); + count_query.push(" WHERE media.owner_id = "); + count_query.push_bind(user_id); - query.push_bind(user_id); + let (mut count_query, metadata_filter_count) = self + .query_builder + .apply_filters_to_query(count_query, options)?; - query = self.query_builder.apply_options_to_query(query, options)?; + if metadata_filter_count > 0 { + count_query.push(" GROUP BY media.id "); + count_query.push(" HAVING COUNT(DISTINCT mm.tag_name) = "); + count_query.push_bind(metadata_filter_count); - let pg_media = query + let mut final_count_query = sqlx::QueryBuilder::new("SELECT COUNT(*) as total FROM ("); + final_count_query.push(count_query.into_sql()); + final_count_query.push(") as subquery"); + count_query = final_count_query; + } + + let total_items_result = count_query + .build_query_scalar() + .fetch_one(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path FROM media"; + let mut data_query = sqlx::QueryBuilder::new(data_base_sql); + data_query.push(" WHERE media.owner_id = "); + data_query.push_bind(user_id); + + let (mut data_query, metadata_filter_count) = self + .query_builder + .apply_filters_to_query(data_query, options)?; + + if metadata_filter_count > 0 { + data_query.push(" GROUP BY media.id "); + data_query.push(" HAVING COUNT(DISTINCT mm.tag_name) = "); + data_query.push_bind(metadata_filter_count); + } + + data_query = self + .query_builder + .apply_sorting_to_query(data_query, options)?; + data_query = self + .query_builder + .apply_pagination_to_query(data_query, options)?; + + let pg_media = data_query .build_query_as::() .fetch_all(&self.pool) .await .map_err(|e| CoreError::Database(e.to_string()))?; let media_list = pg_media.into_iter().map(|m| m.into()).collect(); - Ok(media_list) + Ok((media_list, total_items_result)) } async fn update_thumbnail_path(&self, id: Uuid, thumbnail_path: String) -> CoreResult<()> {