Skip to content

Commit 8047331

Browse files
authored
Admin API filter to search users by username (#5015)
2 parents 18cb990 + a7e56b3 commit 8047331

File tree

7 files changed

+86
-3
lines changed

7 files changed

+86
-3
lines changed

crates/handlers/src/admin/v1/users/list.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ pub struct FilterParams {
5858
#[serde(rename = "filter[legacy-guest]")]
5959
legacy_guest: Option<bool>,
6060

61+
/// Retrieve users where the username matches contains the given string
62+
///
63+
/// Note that this doesn't change the ordering of the result, which are
64+
/// still ordered by ID.
65+
#[serde(rename = "filter[search]")]
66+
search: Option<String>,
67+
6168
/// Retrieve the items with the given status
6269
///
6370
/// Defaults to retrieve all users, including locked ones.
@@ -83,6 +90,10 @@ impl std::fmt::Display for FilterParams {
8390
write!(f, "{sep}filter[legacy-guest]={legacy_guest}")?;
8491
sep = '&';
8592
}
93+
if let Some(search) = &self.search {
94+
write!(f, "{sep}filter[search]={search}")?;
95+
sep = '&';
96+
}
8697
if let Some(status) = self.status {
8798
write!(f, "{sep}filter[status]={status}")?;
8899
sep = '&';
@@ -157,6 +168,11 @@ pub async fn handler(
157168
None => filter,
158169
};
159170

171+
let filter = match params.search.as_deref() {
172+
Some(search) => filter.matching_search(search),
173+
None => filter,
174+
};
175+
160176
let filter = match params.status {
161177
Some(UserStatus::Active) => filter.active_only(),
162178
Some(UserStatus::Locked) => filter.locked_only(),
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- no-transaction
2+
-- Copyright 2025 New Vector Ltd.
3+
--
4+
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
-- Please see LICENSE in the repository root for full details.
6+
7+
-- This enables the pg_trgm extension, which is used for search filters
8+
-- Starting Posgres 16, this extension is marked as "trusted", meaning it can be
9+
-- installed by non-superusers
10+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- no-transaction
2+
-- Copyright 2025 New Vector Ltd.
3+
--
4+
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
-- Please see LICENSE in the repository root for full details.
6+
7+
-- This adds an index on the username field for ILIKE '%search%' operations,
8+
-- enabling fuzzy searches of usernames
9+
CREATE INDEX CONCURRENTLY users_username_trgm_idx
10+
ON users USING gin(username gin_trgm_ops);

crates/storage-pg/src/user/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use async_trait::async_trait;
1111
use mas_data_model::{Clock, User};
1212
use mas_storage::user::{UserFilter, UserRepository};
1313
use rand::RngCore;
14-
use sea_query::{Expr, PostgresQueryBuilder, Query};
14+
use sea_query::{Expr, PostgresQueryBuilder, Query, extension::postgres::PgExpr as _};
1515
use sea_query_binder::SqlxBinder;
1616
use sqlx::PgConnection;
1717
use ulid::Ulid;
@@ -120,6 +120,9 @@ impl Filter for UserFilter<'_> {
120120
self.is_guest()
121121
.map(|is_guest| Expr::col((Users::Table, Users::IsGuest)).eq(is_guest)),
122122
)
123+
.add_option(self.search().map(|search| {
124+
Expr::col((Users::Table, Users::Username)).ilike(format!("%{search}%"))
125+
}))
123126
}
124127
}
125128

crates/storage-pg/src/user/tests.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ async fn test_user_repo(pool: PgPool) {
174174
assert_eq!(repo.user().count(locked).await.unwrap(), 0);
175175
assert_eq!(repo.user().count(deactivated).await.unwrap(), 1);
176176

177+
// Test the search filter
178+
assert_eq!(
179+
repo.user()
180+
.count(all.matching_search("alice"))
181+
.await
182+
.unwrap(),
183+
0
184+
);
185+
assert_eq!(
186+
repo.user().count(all.matching_search("JO")).await.unwrap(),
187+
1
188+
);
189+
177190
// Check the list method
178191
let list = repo.user().list(all, Pagination::first(10)).await.unwrap();
179192
assert_eq!(list.edges.len(), 1);

crates/storage/src/user/mod.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ pub struct UserFilter<'a> {
7676
state: Option<UserState>,
7777
can_request_admin: Option<bool>,
7878
is_guest: Option<bool>,
79-
_phantom: std::marker::PhantomData<&'a ()>,
79+
search: Option<&'a str>,
8080
}
8181

82-
impl UserFilter<'_> {
82+
impl<'a> UserFilter<'a> {
8383
/// Create a new [`UserFilter`] with default values
8484
#[must_use]
8585
pub fn new() -> Self {
@@ -135,6 +135,13 @@ impl UserFilter<'_> {
135135
self
136136
}
137137

138+
/// Filter for users that match the given search string
139+
#[must_use]
140+
pub fn matching_search(mut self, search: &'a str) -> Self {
141+
self.search = Some(search);
142+
self
143+
}
144+
138145
/// Get the state filter
139146
///
140147
/// Returns [`None`] if no state filter was set
@@ -158,6 +165,14 @@ impl UserFilter<'_> {
158165
pub fn is_guest(&self) -> Option<bool> {
159166
self.is_guest
160167
}
168+
169+
/// Get the search filter
170+
///
171+
/// Returns [`None`] if no search filter was set
172+
#[must_use]
173+
pub fn search(&self) -> Option<&'a str> {
174+
self.search
175+
}
161176
}
162177

163178
/// A [`UserRepository`] helps interacting with [`User`] saved in the storage

docs/api/spec.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,17 @@
915915
},
916916
"style": "form"
917917
},
918+
{
919+
"in": "query",
920+
"name": "filter[search]",
921+
"description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.",
922+
"schema": {
923+
"description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.",
924+
"type": "string",
925+
"nullable": true
926+
},
927+
"style": "form"
928+
},
918929
{
919930
"in": "query",
920931
"name": "filter[status]",
@@ -3893,6 +3904,11 @@
38933904
"type": "boolean",
38943905
"nullable": true
38953906
},
3907+
"filter[search]": {
3908+
"description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.",
3909+
"type": "string",
3910+
"nullable": true
3911+
},
38963912
"filter[status]": {
38973913
"description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users",
38983914
"$ref": "#/components/schemas/UserStatus",

0 commit comments

Comments
 (0)