perf: optimize project list queries with batch fetching for tags and media

This commit is contained in:
2026-01-15 16:00:35 -06:00
parent 5e0029c98b
commit 574880b520
5 changed files with 263 additions and 12 deletions
@@ -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"
}
@@ -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"
}
+47
View File
@@ -250,6 +250,53 @@ pub async fn get_media_for_project(
.await .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<std::collections::HashMap<Uuid, Vec<DbProjectMedia>>, 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<Uuid, Vec<DbProjectMedia>> = 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 /// Get single media item by ID
pub async fn get_media_by_id( pub async fn get_media_by_id(
pool: &PgPool, pool: &PgPool,
+44 -12
View File
@@ -6,9 +6,9 @@ use uuid::Uuid;
use super::{ use super::{
ProjectStatus, ProjectStatus,
media::{ApiProjectMedia, DbProjectMedia, get_media_for_project}, media::{self, ApiProjectMedia, DbProjectMedia, get_media_for_project},
slugify, slugify,
tags::{ApiTag, DbTag, get_tags_for_project}, tags::{self, ApiTag, DbTag, get_tags_for_project},
}; };
// Database model // Database model
@@ -182,13 +182,29 @@ pub async fn get_public_projects_with_tags(
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> { ) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
let projects = get_public_projects(pool).await?; let projects = get_public_projects(pool).await?;
let mut result = Vec::new(); if projects.is_empty() {
for project in projects { return Ok(Vec::new());
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));
} }
// Collect project IDs for batch queries
let project_ids: Vec<Uuid> = 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) Ok(result)
} }
@@ -222,13 +238,29 @@ pub async fn get_all_projects_with_tags_admin(
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> { ) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
let projects = get_all_projects_admin(pool).await?; let projects = get_all_projects_admin(pool).await?;
let mut result = Vec::new(); if projects.is_empty() {
for project in projects { return Ok(Vec::new());
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));
} }
// Collect project IDs for batch queries
let project_ids: Vec<Uuid> = 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) Ok(result)
} }
+46
View File
@@ -244,6 +244,52 @@ pub async fn get_tags_for_project(
.await .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<std::collections::HashMap<Uuid, Vec<DbTag>>, 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<Uuid, Vec<DbTag>> = 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( pub async fn get_projects_for_tag(
pool: &PgPool, pool: &PgPool,
tag_id: Uuid, tag_id: Uuid,