Admin API filter to search users by username

This commit is contained in:
Quentin Gliech
2025-09-15 12:37:48 +02:00
parent d458160c53
commit cb8c408489
7 changed files with 86 additions and 3 deletions

View File

@@ -58,6 +58,13 @@ pub struct FilterParams {
#[serde(rename = "filter[legacy-guest]")]
legacy_guest: Option<bool>,
/// Retrieve users where the username matches contains the given string
///
/// Note that this doesn't change the ordering of the result, which are
/// still ordered by ID.
#[serde(rename = "filter[search]")]
search: Option<String>,
/// Retrieve the items with the given status
///
/// Defaults to retrieve all users, including locked ones.
@@ -83,6 +90,10 @@ impl std::fmt::Display for FilterParams {
write!(f, "{sep}filter[legacy-guest]={legacy_guest}")?;
sep = '&';
}
if let Some(search) = &self.search {
write!(f, "{sep}filter[search]={search}")?;
sep = '&';
}
if let Some(status) = self.status {
write!(f, "{sep}filter[status]={status}")?;
sep = '&';
@@ -157,6 +168,11 @@ pub async fn handler(
None => filter,
};
let filter = match params.search.as_deref() {
Some(search) => filter.matching_search(search),
None => filter,
};
let filter = match params.status {
Some(UserStatus::Active) => filter.active_only(),
Some(UserStatus::Locked) => filter.locked_only(),

View File

@@ -0,0 +1,10 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-- Please see LICENSE in the repository root for full details.
-- This enables the pg_trgm extension, which is used for search filters
-- Starting Posgres 16, this extension is marked as "trusted", meaning it can be
-- installed by non-superusers
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,10 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-- Please see LICENSE in the repository root for full details.
-- This adds an index on the username field for ILIKE '%search%' operations,
-- enabling fuzzy searches of usernames
CREATE INDEX CONCURRENTLY users_username_trgm_idx
ON users USING gin(username gin_trgm_ops);

View File

@@ -11,7 +11,7 @@ use async_trait::async_trait;
use mas_data_model::{Clock, User};
use mas_storage::user::{UserFilter, UserRepository};
use rand::RngCore;
use sea_query::{Expr, PostgresQueryBuilder, Query};
use sea_query::{Expr, PostgresQueryBuilder, Query, extension::postgres::PgExpr as _};
use sea_query_binder::SqlxBinder;
use sqlx::PgConnection;
use ulid::Ulid;
@@ -120,6 +120,9 @@ impl Filter for UserFilter<'_> {
self.is_guest()
.map(|is_guest| Expr::col((Users::Table, Users::IsGuest)).eq(is_guest)),
)
.add_option(self.search().map(|search| {
Expr::col((Users::Table, Users::Username)).ilike(format!("%{search}%"))
}))
}
}

View File

@@ -174,6 +174,19 @@ async fn test_user_repo(pool: PgPool) {
assert_eq!(repo.user().count(locked).await.unwrap(), 0);
assert_eq!(repo.user().count(deactivated).await.unwrap(), 1);
// Test the search filter
assert_eq!(
repo.user()
.count(all.matching_search("alice"))
.await
.unwrap(),
0
);
assert_eq!(
repo.user().count(all.matching_search("JO")).await.unwrap(),
1
);
// Check the list method
let list = repo.user().list(all, Pagination::first(10)).await.unwrap();
assert_eq!(list.edges.len(), 1);

View File

@@ -76,10 +76,10 @@ pub struct UserFilter<'a> {
state: Option<UserState>,
can_request_admin: Option<bool>,
is_guest: Option<bool>,
_phantom: std::marker::PhantomData<&'a ()>,
search: Option<&'a str>,
}
impl UserFilter<'_> {
impl<'a> UserFilter<'a> {
/// Create a new [`UserFilter`] with default values
#[must_use]
pub fn new() -> Self {
@@ -135,6 +135,13 @@ impl UserFilter<'_> {
self
}
/// Filter for users that match the given search string
#[must_use]
pub fn matching_search(mut self, search: &'a str) -> Self {
self.search = Some(search);
self
}
/// Get the state filter
///
/// Returns [`None`] if no state filter was set
@@ -158,6 +165,14 @@ impl UserFilter<'_> {
pub fn is_guest(&self) -> Option<bool> {
self.is_guest
}
/// Get the search filter
///
/// Returns [`None`] if no search filter was set
#[must_use]
pub fn search(&self) -> Option<&'a str> {
self.search
}
}
/// A [`UserRepository`] helps interacting with [`User`] saved in the storage

View File

@@ -915,6 +915,17 @@
},
"style": "form"
},
{
"in": "query",
"name": "filter[search]",
"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.",
"schema": {
"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.",
"type": "string",
"nullable": true
},
"style": "form"
},
{
"in": "query",
"name": "filter[status]",
@@ -3893,6 +3904,11 @@
"type": "boolean",
"nullable": true
},
"filter[search]": {
"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.",
"type": "string",
"nullable": true
},
"filter[status]": {
"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",
"$ref": "#/components/schemas/UserStatus",