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>
This commit is contained in:
Calum H.
2026-06-03 20:38:24 +01:00
committed by GitHub
parent d907083d83
commit 58ad58f958
6 changed files with 237 additions and 2 deletions
@@ -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"
}
@@ -0,0 +1,2 @@
CREATE INDEX users_lowercase_username_pattern
ON users (LOWER(username) text_pattern_ops);
@@ -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<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct Pride26CampaignDonation {
pub last_donated_at: DateTime<Utc>,
@@ -264,6 +271,44 @@ impl DBUser {
Ok(val)
}
pub async fn search<'a, E>(
query: &str,
exec: E,
) -> Result<Vec<DBSearchUser>, 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
}
+20 -1
View File
@@ -73,6 +73,13 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchUser {
pub id: UserId,
pub username: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserCampaigns {
pub pride_26: Option<Pride26CampaignDonation>,
@@ -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<DBUser> for User {
fn from(data: DBUser) -> Self {
@@ -116,6 +125,16 @@ impl From<DBUser> for User {
}
}
impl From<DBSearchUser> 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();
+21
View File
@@ -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<UserSearchQuery>,
pool: web::Data<PgPool>,
) -> Result<web::Json<Vec<crate::models::users::SearchUser>>, 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<UserIds>,
+103 -1
View File
@@ -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<ApiV3>| 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<serde_json::Value> =
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<ApiV3>| 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<serde_json::Value> =
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<serde_json::Value> =
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<serde_json::Value> =
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 {