diff --git a/.sqlx/query-1bd6b8e70790c3bc84bd42182622a417f338c179fe01d8324691e374c6f98d58.json b/.sqlx/query-1bd6b8e70790c3bc84bd42182622a417f338c179fe01d8324691e374c6f98d58.json new file mode 100644 index 0000000..90272b3 --- /dev/null +++ b/.sqlx/query-1bd6b8e70790c3bc84bd42182622a417f338c179fe01d8324691e374c6f98d58.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n id,\n project_id,\n display_order,\n media_type as \"media_type: MediaType\",\n r2_base_path,\n variants,\n blurhash,\n metadata\n FROM project_media\n WHERE project_id = ANY($1)\n ORDER BY project_id, display_order ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_order", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "media_type: MediaType", + "type_info": { + "Custom": { + "name": "media_type", + "kind": { + "Enum": [ + "image", + "video" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "r2_base_path", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "variants", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "blurhash", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "1bd6b8e70790c3bc84bd42182622a417f338c179fe01d8324691e374c6f98d58" +} diff --git a/.sqlx/query-fd27337c6fd48ad71bf840da7764907adcc47e9f69fa0d821640c2ed7cbff269.json b/.sqlx/query-fd27337c6fd48ad71bf840da7764907adcc47e9f69fa0d821640c2ed7cbff269.json new file mode 100644 index 0000000..6bb11b8 --- /dev/null +++ b/.sqlx/query-fd27337c6fd48ad71bf840da7764907adcc47e9f69fa0d821640c2ed7cbff269.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT pt.project_id, t.id, t.slug, t.name, t.icon, t.color\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = ANY($1)\n ORDER BY pt.project_id, t.name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "color", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "fd27337c6fd48ad71bf840da7764907adcc47e9f69fa0d821640c2ed7cbff269" +} diff --git a/src/db/media.rs b/src/db/media.rs index 709cdca..a3d30d8 100644 --- a/src/db/media.rs +++ b/src/db/media.rs @@ -250,6 +250,53 @@ pub async fn get_media_for_project( .await } +/// Batch fetch media for multiple projects in a single query. +/// Returns a HashMap mapping project_id to its media items. +pub async fn get_media_for_projects( + pool: &PgPool, + project_ids: &[Uuid], +) -> Result>, sqlx::Error> { + use std::collections::HashMap; + + if project_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows = sqlx::query_as!( + DbProjectMedia, + r#" + SELECT + id, + project_id, + display_order, + media_type as "media_type: MediaType", + r2_base_path, + variants, + blurhash, + metadata + FROM project_media + WHERE project_id = ANY($1) + ORDER BY project_id, display_order ASC + "#, + project_ids + ) + .fetch_all(pool) + .await?; + + let mut result: HashMap> = HashMap::new(); + + // Initialize empty vecs for all requested project_ids + for &id in project_ids { + result.entry(id).or_default(); + } + + for media in rows { + result.entry(media.project_id).or_default().push(media); + } + + Ok(result) +} + /// Get single media item by ID pub async fn get_media_by_id( pool: &PgPool, diff --git a/src/db/projects.rs b/src/db/projects.rs index 355f242..f433fd1 100644 --- a/src/db/projects.rs +++ b/src/db/projects.rs @@ -6,9 +6,9 @@ use uuid::Uuid; use super::{ ProjectStatus, - media::{ApiProjectMedia, DbProjectMedia, get_media_for_project}, + media::{self, ApiProjectMedia, DbProjectMedia, get_media_for_project}, slugify, - tags::{ApiTag, DbTag, get_tags_for_project}, + tags::{self, ApiTag, DbTag, get_tags_for_project}, }; // Database model @@ -182,13 +182,29 @@ pub async fn get_public_projects_with_tags( ) -> Result, Vec)>, sqlx::Error> { let projects = get_public_projects(pool).await?; - let mut result = Vec::new(); - for project in projects { - let tags = get_tags_for_project(pool, project.id).await?; - let media = get_media_for_project(pool, project.id).await?; - result.push((project, tags, media)); + if projects.is_empty() { + return Ok(Vec::new()); } + // Collect project IDs for batch queries + let project_ids: Vec = projects.iter().map(|p| p.id).collect(); + + // Batch fetch all tags and media in 2 queries instead of N*2 + let (tags_map, media_map) = tokio::try_join!( + tags::get_tags_for_projects(pool, &project_ids), + media::get_media_for_projects(pool, &project_ids), + )?; + + // Assemble results + let result = projects + .into_iter() + .map(|project| { + let tags = tags_map.get(&project.id).cloned().unwrap_or_default(); + let media = media_map.get(&project.id).cloned().unwrap_or_default(); + (project, tags, media) + }) + .collect(); + Ok(result) } @@ -222,13 +238,29 @@ pub async fn get_all_projects_with_tags_admin( ) -> Result, Vec)>, sqlx::Error> { let projects = get_all_projects_admin(pool).await?; - let mut result = Vec::new(); - for project in projects { - let tags = get_tags_for_project(pool, project.id).await?; - let media = get_media_for_project(pool, project.id).await?; - result.push((project, tags, media)); + if projects.is_empty() { + return Ok(Vec::new()); } + // Collect project IDs for batch queries + let project_ids: Vec = projects.iter().map(|p| p.id).collect(); + + // Batch fetch all tags and media in 2 queries instead of N*2 + let (tags_map, media_map) = tokio::try_join!( + tags::get_tags_for_projects(pool, &project_ids), + media::get_media_for_projects(pool, &project_ids), + )?; + + // Assemble results + let result = projects + .into_iter() + .map(|project| { + let tags = tags_map.get(&project.id).cloned().unwrap_or_default(); + let media = media_map.get(&project.id).cloned().unwrap_or_default(); + (project, tags, media) + }) + .collect(); + Ok(result) } diff --git a/src/db/tags.rs b/src/db/tags.rs index e0bc9c1..1af6700 100644 --- a/src/db/tags.rs +++ b/src/db/tags.rs @@ -244,6 +244,52 @@ pub async fn get_tags_for_project( .await } +/// Batch fetch tags for multiple projects in a single query. +/// Returns a HashMap mapping project_id to its tags. +pub async fn get_tags_for_projects( + pool: &PgPool, + project_ids: &[Uuid], +) -> Result>, sqlx::Error> { + use std::collections::HashMap; + + if project_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows = sqlx::query!( + r#" + SELECT pt.project_id, t.id, t.slug, t.name, t.icon, t.color + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = ANY($1) + ORDER BY pt.project_id, t.name ASC + "#, + project_ids + ) + .fetch_all(pool) + .await?; + + let mut result: HashMap> = HashMap::new(); + + // Initialize empty vecs for all requested project_ids + for &id in project_ids { + result.entry(id).or_default(); + } + + for row in rows { + let tag = DbTag { + id: row.id, + slug: row.slug, + name: row.name, + icon: row.icon, + color: row.color, + }; + result.entry(row.project_id).or_default().push(tag); + } + + Ok(result) +} + pub async fn get_projects_for_tag( pool: &PgPool, tag_id: Uuid,