From 58ad58f9581b32d9aa8ae4119b3aa0e3c1beb7fd Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 3 Jun 2026 20:38:24 +0100 Subject: [PATCH] feat: user search (#6302) * feat: user search * refactor: use sqlx macro * refactor: better name for escaped query * style: cleanup unused import * chore: update sqlx query cache * fix: tests --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com> --- ...1b9b3cc1a59f7cdee756ac5ce1c459e69a531.json | 35 ++++++ .../20260603120000_user-search-index.sql | 2 + .../labrinth/src/database/models/user_item.rs | 56 ++++++++++ apps/labrinth/src/models/v3/users.rs | 21 +++- apps/labrinth/src/routes/v3/users.rs | 21 ++++ apps/labrinth/tests/user.rs | 104 +++++++++++++++++- 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531.json create mode 100644 apps/labrinth/migrations/20260603120000_user-search-index.sql diff --git a/apps/labrinth/.sqlx/query-8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531.json b/apps/labrinth/.sqlx/query-8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531.json new file mode 100644 index 000000000..80120ce39 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE ''\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "avatar_url", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531" +} diff --git a/apps/labrinth/migrations/20260603120000_user-search-index.sql b/apps/labrinth/migrations/20260603120000_user-search-index.sql new file mode 100644 index 000000000..b9af25c4d --- /dev/null +++ b/apps/labrinth/migrations/20260603120000_user-search-index.sql @@ -0,0 +1,2 @@ +CREATE INDEX users_lowercase_username_pattern +ON users (LOWER(username) text_pattern_ops); diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index 9fbc3b3d0..d0df6460d 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -57,6 +57,13 @@ pub struct DBUser { pub is_subscribed_to_newsletter: bool, } +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct DBSearchUser { + pub id: DBUserId, + pub username: String, + pub avatar_url: Option, +} + #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct Pride26CampaignDonation { pub last_donated_at: DateTime, @@ -264,6 +271,44 @@ impl DBUser { Ok(val) } + pub async fn search<'a, E>( + query: &str, + exec: E, + ) -> Result, sqlx::Error> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + if query.is_empty() { + return Ok(Vec::new()); + } + + let lowercase_query = query.to_lowercase(); + let escaped_query = format!("{}%", escape_like(&lowercase_query)); + + let users = sqlx::query!( + " + SELECT id, username, avatar_url + FROM users + WHERE LOWER(username) LIKE $1 ESCAPE '\' + ORDER BY LOWER(username) = $2 DESC, LOWER(username), username + LIMIT 25 + ", + escaped_query, + lowercase_query + ) + .fetch_all(exec) + .await? + .into_iter() + .map(|row| DBSearchUser { + id: DBUserId(row.id), + username: row.username, + avatar_url: row.avatar_url, + }) + .collect(); + + Ok(users) + } + pub async fn get_by_email<'a, E>( email: &str, exec: E, @@ -1044,3 +1089,14 @@ impl DBUser { } } } + +fn escape_like(query: &str) -> String { + let mut escaped = String::with_capacity(query.len()); + for ch in query.chars() { + if matches!(ch, '\\' | '%' | '_') { + escaped.push('\\'); + } + escaped.push(ch); + } + escaped +} diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 89de2cccd..280b58f78 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -73,6 +73,13 @@ pub struct User { pub github_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SearchUser { + pub id: UserId, + pub username: String, + pub avatar_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserCampaigns { pub pride_26: Option, @@ -87,7 +94,9 @@ pub struct UserPayoutData { pub balance: Decimal, } -use crate::database::models::user_item::{DBUser, Pride26CampaignDonation}; +use crate::database::models::user_item::{ + DBSearchUser, DBUser, Pride26CampaignDonation, +}; impl From for User { fn from(data: DBUser) -> Self { @@ -116,6 +125,16 @@ impl From for User { } } +impl From for SearchUser { + fn from(data: DBSearchUser) -> Self { + Self { + id: data.id.into(), + username: data.username, + avatar_url: data.avatar_url, + } + } +} + impl User { pub fn from_full(db_user: DBUser) -> Self { let mut auth_providers = Vec::new(); diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index c76e71845..b33457e7d 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -39,6 +39,7 @@ use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { cfg.route("user", web::get().to(user_auth_get)); cfg.route("users", web::get().to(users_get)); + cfg.route("users/search", web::get().to(users_search)); cfg.route("user_email", web::get().to(admin_user_email)); cfg.service( @@ -327,6 +328,26 @@ pub struct UserIds { pub ids: String, } +#[derive(Deserialize)] +pub struct UserSearchQuery { + pub query: String, +} + +pub async fn users_search( + web::Query(query): web::Query, + pool: web::Data, +) -> Result>, ApiError> { + let query = query.query.trim(); + let users = DBUser::search(query, &**pool) + .await + .wrap_internal_err("failed to search users")? + .into_iter() + .map(Into::into) + .collect(); + + Ok(web::Json(users)) +} + pub async fn users_get( req: HttpRequest, web::Query(ids): web::Query, diff --git a/apps/labrinth/tests/user.rs b/apps/labrinth/tests/user.rs index 340b50031..5be41d577 100644 --- a/apps/labrinth/tests/user.rs +++ b/apps/labrinth/tests/user.rs @@ -1,9 +1,13 @@ use crate::common::api_common::{ApiProject, ApiTeams}; +use actix_web::test; use common::dummy_data::TestFile; use common::{ database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT}, - environment::with_test_environment_all, + environment::{ + TestEnvironment, with_test_environment, with_test_environment_all, + }, }; +use labrinth::test::api_v3::ApiV3; pub mod common; @@ -16,6 +20,104 @@ pub mod common; // patch user icon // user follows +#[actix_rt::test] +pub async fn search_users_returns_compact_prefix_matches_with_exact_first() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + sqlx::query( + " + INSERT INTO users (id, username, email, role) + VALUES + (1000, 'userland', 'userland@modrinth.com', 'developer'), + (1001, 'useful', 'useful@modrinth.com', 'developer'), + (1002, 'Useless', 'useless@modrinth.com', 'developer') + ", + ) + .execute(&*test_env.db.pool) + .await + .unwrap(); + + let req = test::TestRequest::get() + .uri("/v3/users/search?query=user") + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::OK); + + let users: Vec = + test::read_body_json(resp).await; + assert_eq!(users.len(), 2); + assert_eq!(users[0]["username"], "User"); + assert_eq!(users[1]["username"], "userland"); + assert!(users.iter().all(|user| { + user.as_object().is_some_and(|object| { + object.len() == 3 && object.contains_key("avatar_url") + }) + })); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn search_users_escapes_wildcards_and_limits_results() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + for i in 0..30 { + sqlx::query( + " + INSERT INTO users (id, username, email, role) + VALUES ($1, $2, $3, 'developer') + ", + ) + .bind(2000 + i) + .bind(format!("prefix{i:02}")) + .bind(format!("prefix{i:02}@modrinth.com")) + .execute(&*test_env.db.pool) + .await + .unwrap(); + } + + let req = test::TestRequest::get() + .uri("/v3/users/search?query=prefix") + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::OK); + + let users: Vec = + test::read_body_json(resp).await; + assert_eq!(users.len(), 25); + assert!(users.iter().all(|user| { + user["username"] + .as_str() + .is_some_and(|username| username.starts_with("prefix")) + })); + + let req = test::TestRequest::get() + .uri("/v3/users/search?query=%25") + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::OK); + + let users: Vec = + test::read_body_json(resp).await; + assert!(users.is_empty()); + + let req = test::TestRequest::get() + .uri("/v3/users/search?query=%20%20") + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::OK); + + let users: Vec = + test::read_body_json(resp).await; + assert!(users.is_empty()); + }, + ) + .await; +} + #[actix_rt::test] pub async fn get_user_projects_after_creating_project_returns_new_project() { with_test_environment_all(None, |test_env| async move {