mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
feat: user search
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE INDEX users_lowercase_username_pattern
|
||||||
|
ON users (LOWER(username) text_pattern_ops);
|
||||||
@@ -13,6 +13,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
use std::fmt::{Debug, Display};
|
use std::fmt::{Debug, Display};
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ pub struct DBUser {
|
|||||||
pub is_subscribed_to_newsletter: bool,
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct Pride26CampaignDonation {
|
pub struct Pride26CampaignDonation {
|
||||||
pub last_donated_at: DateTime<Utc>,
|
pub last_donated_at: DateTime<Utc>,
|
||||||
@@ -264,6 +272,44 @@ impl DBUser {
|
|||||||
Ok(val)
|
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 pattern = 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
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(pattern)
|
||||||
|
.bind(lowercase_query)
|
||||||
|
.fetch_all(exec)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| DBSearchUser {
|
||||||
|
id: DBUserId(row.get("id")),
|
||||||
|
username: row.get("username"),
|
||||||
|
avatar_url: row.get("avatar_url"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_by_email<'a, E>(
|
pub async fn get_by_email<'a, E>(
|
||||||
email: &str,
|
email: &str,
|
||||||
exec: E,
|
exec: E,
|
||||||
@@ -1044,3 +1090,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ pub struct User {
|
|||||||
pub github_id: Option<u64>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct UserCampaigns {
|
pub struct UserCampaigns {
|
||||||
pub pride_26: Option<Pride26CampaignDonation>,
|
pub pride_26: Option<Pride26CampaignDonation>,
|
||||||
@@ -87,7 +94,9 @@ pub struct UserPayoutData {
|
|||||||
pub balance: Decimal,
|
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 {
|
impl From<DBUser> for User {
|
||||||
fn from(data: DBUser) -> Self {
|
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 {
|
impl User {
|
||||||
pub fn from_full(db_user: DBUser) -> Self {
|
pub fn from_full(db_user: DBUser) -> Self {
|
||||||
let mut auth_providers = Vec::new();
|
let mut auth_providers = Vec::new();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use validator::Validate;
|
|||||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.route("user", web::get().to(user_auth_get));
|
cfg.route("user", web::get().to(user_auth_get));
|
||||||
cfg.route("users", web::get().to(users_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.route("user_email", web::get().to(admin_user_email));
|
||||||
|
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -327,6 +328,26 @@ pub struct UserIds {
|
|||||||
pub ids: String,
|
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(
|
pub async fn users_get(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
web::Query(ids): web::Query<UserIds>,
|
web::Query(ids): web::Query<UserIds>,
|
||||||
|
|||||||
+102
-1
@@ -1,9 +1,13 @@
|
|||||||
use crate::common::api_common::{ApiProject, ApiTeams};
|
use crate::common::api_common::{ApiProject, ApiTeams};
|
||||||
|
use actix_web::test;
|
||||||
use common::dummy_data::TestFile;
|
use common::dummy_data::TestFile;
|
||||||
use common::{
|
use common::{
|
||||||
database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT},
|
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;
|
pub mod common;
|
||||||
|
|
||||||
@@ -16,6 +20,103 @@ pub mod common;
|
|||||||
// patch user icon
|
// patch user icon
|
||||||
// user follows
|
// 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["avatar_url"].is_string()));
|
||||||
|
assert!(users.iter().all(|user| {
|
||||||
|
user.as_object().is_some_and(|object| object.len() == 3)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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]
|
#[actix_rt::test]
|
||||||
pub async fn get_user_projects_after_creating_project_returns_new_project() {
|
pub async fn get_user_projects_after_creating_project_returns_new_project() {
|
||||||
with_test_environment_all(None, |test_env| async move {
|
with_test_environment_all(None, |test_env| async move {
|
||||||
|
|||||||
Reference in New Issue
Block a user