nexus_common/models/user/search.rs
1use super::UserDetails;
2use crate::db::RedisOps;
3use crate::{models::traits::Collection, types::DynError};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7pub const USER_NAME_KEY_PARTS: [&str; 2] = ["Users", "Name"];
8
9#[derive(Serialize, Deserialize, ToSchema, Default)]
10pub struct UserSearch(pub Vec<String>);
11
12impl RedisOps for UserSearch {}
13
14impl UserSearch {
15 pub async fn get_by_name(
16 name: &str,
17 skip: Option<usize>,
18 limit: Option<usize>,
19 ) -> Result<Option<Self>, DynError> {
20 // Perform the lexicographical range search
21 let elements = Self::get_from_index(name, skip, limit).await?;
22
23 // If elements exist, process them to extract user_ids
24 if let Some(elements) = elements {
25 let user_ids: Vec<String> = elements
26 .into_iter()
27 .filter_map(|element| {
28 // Split by `:` and take the last part (user_id)
29 element.split(':').next_back().map(|p| p.to_string())
30 })
31 .collect();
32
33 return Ok(Some(UserSearch(user_ids)));
34 }
35
36 Ok(None)
37 }
38
39 pub async fn get_from_index(
40 name: &str,
41 skip: Option<usize>,
42 limit: Option<usize>,
43 ) -> Result<Option<Vec<String>>, DynError> {
44 // Convert the username to lowercase to ensure case-insensitive search
45 let name = name.to_lowercase();
46
47 let min = format!("[{name}"); // Inclusive range starting with "name"
48 let max = format!("({name}~"); // Exclusive range ending just after "name"
49
50 // Perform the lexicographical range search
51 Self::try_from_index_sorted_set_lex(&USER_NAME_KEY_PARTS, &min, &max, skip, limit).await
52 }
53
54 /// Adds multiple `user_id`s to the Redis sorted set using the username as index.
55 ///
56 /// This method takes a list of `UserDetails` and adds them all to the sorted set at once.
57 pub async fn put_to_index(details_list: &[&UserDetails]) -> Result<(), DynError> {
58 // ensure existing records are deleted
59 Self::delete_existing_records(
60 details_list
61 .iter()
62 .map(|details| details.id.as_str())
63 .collect::<Vec<&str>>()
64 .as_slice(),
65 )
66 .await?;
67
68 // Collect all the `username:user_id` pairs and their corresponding scores
69 let mut items: Vec<(f64, String)> = Vec::with_capacity(details_list.len());
70
71 for details in details_list {
72 // Convert the username to lowercase before storing
73 if details.name == "[DELETED]" {
74 break;
75 }
76 let username = details.name.to_lowercase();
77 let user_id = &details.id;
78 let score = 0.0;
79
80 // The value in the sorted set will be `username:user_id`
81 let member = format!("{username}:{user_id}");
82
83 items.push((score, member));
84 }
85
86 // Perform a single Redis ZADD operation with all the items
87 Self::put_index_sorted_set(
88 &USER_NAME_KEY_PARTS,
89 &items
90 .iter()
91 .map(|(score, member)| (*score, member.as_str()))
92 .collect::<Vec<_>>(),
93 None,
94 None,
95 )
96 .await
97 }
98
99 async fn delete_existing_records(user_ids: &[&str]) -> Result<(), DynError> {
100 if user_ids.is_empty() {
101 return Ok(());
102 }
103 let mut records_to_delete: Vec<String> = Vec::with_capacity(user_ids.len());
104 let keys = user_ids
105 .iter()
106 .map(|&id| vec![id])
107 .collect::<Vec<Vec<&str>>>();
108 let users = UserDetails::get_from_index(keys.iter().map(|item| item.as_slice()).collect())
109 .await?
110 .into_iter()
111 .flatten()
112 .collect::<Vec<UserDetails>>();
113 for user_id in user_ids {
114 let existing_username = users
115 .iter()
116 .find(|user| user.id.to_string() == *user_id)
117 .map(|user| user.name.to_lowercase());
118 if let Some(existing_record) = existing_username {
119 let search_key = format!("{existing_record}:{user_id}");
120 records_to_delete.push(search_key);
121 }
122 }
123
124 Self::remove_from_index_sorted_set(
125 None,
126 &USER_NAME_KEY_PARTS,
127 records_to_delete
128 .iter()
129 .map(|item| item.as_str())
130 .collect::<Vec<&str>>()
131 .as_slice(),
132 )
133 .await?;
134 Ok(())
135 }
136}
137
138// #[cfg(test)]
139// mod tests {
140// use crate::{
141// models::{
142// traits::Collection,
143// user::{UserDetails, UserSearch},
144// },
145// types::DynError,
146// RedisOps,
147// _service::NexusApi,
148// };
149// use chrono::Utc;
150// use pubky_app_specs::PubkyId;
151
152// #[tokio_shared_rt::test(shared)]
153// async fn test_put_to_index_no_duplicates() -> Result<(), DynError> {
154// NexusApi::builder().init_stack().await;
155// // Test that the `put_to_index` method does not add duplicate records to the index
156// // when called with the same `UserDetails` multiple times.
157
158// // Create a `UserDetails` object
159// let user_id = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo";
160// let user_name = "Test User Duplicate";
161// let user_details = UserDetails {
162// id: PubkyId::try_from(user_id).expect("valid pubky id"),
163// name: user_name.to_string(),
164// bio: None,
165// status: None,
166// links: None,
167// image: None,
168// indexed_at: Utc::now().timestamp_millis(),
169// };
170
171// user_details.put_to_graph().await?;
172// user_details
173// .put_index_json(vec![user_id].as_slice(), None, None)
174// .await?;
175
176// // Call `put_to_index` with the same `UserDetails` object
177// UserSearch::put_to_index(&[&user_details]).await?;
178
179// // Check that the index contains only one record for the user
180// let search_result = UserSearch::get_by_name(&user_name, None, None).await?;
181// assert_eq!(search_result.unwrap().0, vec![user_id.to_string()]);
182
183// let new_user_name = "Some Other User Name";
184// let new_user_details = UserDetails {
185// id: PubkyId::try_from(user_id).expect("valid pubky id"),
186// name: new_user_name.to_string(),
187// bio: None,
188// status: None,
189// links: None,
190// image: None,
191// indexed_at: Utc::now().timestamp_millis(),
192// };
193
194// // Call `put_to_index` with new user details
195// UserSearch::put_to_index(&[&new_user_details]).await?;
196
197// // Check the previous record is deleted
198// // Check that the index contains only one record for the user
199// let search_result = UserSearch::get_by_name(&user_name, None, None).await?;
200// assert_eq!(search_result.is_none(), true);
201
202// Ok(())
203// }
204//}