Merge remote-tracking branch 'origin/pr/564'

This commit is contained in:
fawney19
2026-05-27 00:52:15 +08:00
13 changed files with 401 additions and 20 deletions
@@ -7,8 +7,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::DataLayerError;
@@ -409,6 +409,34 @@ fn filter_memory_export_rows(
.contains(&search)
});
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
let created_at_by_id = repository
.auth_by_id
.read()
.expect("user repository lock")
.iter()
.filter_map(|(user_id, user)| {
user.created_at
.map(|created_at| (user_id.clone(), created_at.timestamp_millis()))
})
.collect::<BTreeMap<_, _>>();
rows.sort_by(|left, right| {
let primary = created_at_by_id
.get(&left.id)
.cmp(&created_at_by_id.get(&right.id));
let ordered = if query.sort_order.is_desc() {
primary.reverse()
} else {
primary
};
ordered.then_with(|| left.id.cmp(&right.id))
});
}
UserExportSortBy::Id => {
rows.sort_by(|left, right| left.id.cmp(&right.id));
}
}
rows
}
@@ -2682,6 +2710,7 @@ mod tests {
is_active: Some(true),
search: None,
group_id: None,
..Default::default()
})
.await
.expect("paged export should succeed");
@@ -12,5 +12,6 @@ pub use types::{
normalize_user_group_name, StoredUserAuthRecord, StoredUserExportRow, StoredUserGroup,
StoredUserGroupMember, StoredUserGroupMembership, StoredUserOAuthLinkSummary,
StoredUserPreferenceRecord, StoredUserSessionRecord, StoredUserSummary, UpsertUserGroupRecord,
UserExportListQuery, UserExportSummary, UserReadRepository,
UserExportListQuery, UserExportSortBy, UserExportSortOrder, UserExportSummary,
UserReadRepository,
};
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
@@ -313,8 +313,24 @@ impl UserReadRepository for MysqlUserReadRepository {
.push_bind(pattern)
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC LIMIT ")
.push(" LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
})?)
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::{error::SqlxResultExt, DataLayerError};
@@ -1001,8 +1001,24 @@ WHERE user_group_members.user_id IN (
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC OFFSET ")
.push(" OFFSET ")
.push_bind(i64::try_from(query.skip).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export skip: {}", query.skip))
})?)
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
@@ -313,8 +313,24 @@ impl UserReadRepository for SqliteUserReadRepository {
.push_bind(pattern)
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC LIMIT ")
.push(" LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
})?)
@@ -2199,6 +2215,7 @@ INSERT INTO users (
is_active: Some(true),
search: None,
group_id: None,
..Default::default()
})
.await
.expect("export page should load");
@@ -642,6 +642,46 @@ pub struct UserExportListQuery {
pub is_active: Option<bool>,
pub search: Option<String>,
pub group_id: Option<String>,
pub sort_by: UserExportSortBy,
pub sort_order: UserExportSortOrder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortBy {
#[default]
Id,
CreatedAt,
}
impl UserExportSortBy {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"created_at" => Some(Self::CreatedAt),
"id" => Some(Self::Id),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortOrder {
#[default]
Asc,
Desc,
}
impl UserExportSortOrder {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"asc" => Some(Self::Asc),
"desc" => Some(Self::Desc),
_ => None,
}
}
pub fn is_desc(self) -> bool {
matches!(self, Self::Desc)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]