Skip to main content

xz_memory_engine/layered/
user_profile.rs

1//! [`UserProfileMemory`] implementation for [`LayeredMemory`].
2//!
3//! Stores per-user preferences as individual entries in the
4//! `user:{user_id}:profile` partition.  Each entry's `id` is the preference
5//! key and its `body` is the preference value.
6
7use std::collections::HashMap;
8
9use async_trait::async_trait;
10use chrono::Utc;
11use xz_memory_core::{Entry, EntryStore, IndexSearcher, StoreError};
12
13use super::traits::UserProfileMemory;
14
15impl<S: EntryStore, I: IndexSearcher> super::default::LayeredMemory<S, I> {
16    fn user_profile_partition(user_id: &str) -> String {
17        format!("user:{}:profile", user_id)
18    }
19}
20
21#[async_trait]
22impl<S: EntryStore, I: IndexSearcher> UserProfileMemory for super::default::LayeredMemory<S, I> {
23    async fn get_preferences(&self, user_id: &str) -> Result<HashMap<String, String>, StoreError> {
24        let partition = Self::user_profile_partition(user_id);
25        let prefix = format!("{}:", user_id);
26        let opts = xz_memory_core::QueryOptions {
27            limit: usize::MAX,
28            sort: xz_memory_core::SortOrder::Ascending,
29        };
30        let range = xz_memory_core::TimeRange { start: None, end: None };
31        let entries = self.store.query(&partition, &range, &opts).await?;
32        let mut map = HashMap::new();
33        for e in entries {
34            if let Some(key) = e.id.strip_prefix(&prefix) {
35                map.insert(key.to_string(), e.body);
36            }
37        }
38        Ok(map)
39    }
40
41    async fn set_preference(
42        &self,
43        user_id: &str,
44        key: &str,
45        value: &str,
46    ) -> Result<(), StoreError> {
47        let partition = Self::user_profile_partition(user_id);
48        let entry_id = format!("{}:{}", user_id, key);
49        let _ = self.store.delete(&entry_id).await;
50        let entry = Entry {
51            id: entry_id,
52            partition,
53            body: value.to_string(),
54            recorded_at: Utc::now().timestamp_millis() as u64,
55        };
56        self.store.append(entry).await
57    }
58
59    async fn remove_preference(&self, user_id: &str, key: &str) -> Result<(), StoreError> {
60        let entry_id = format!("{}:{}", user_id, key);
61        let _ = self.store.delete(&entry_id).await;
62        Ok(())
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::sync::Arc;
70    use xz_memory_core::{ScoredEntry, SearchOptions};
71
72    use crate::backends::InMemoryEntryStore;
73
74    struct MockSearcher;
75
76    #[async_trait]
77    impl IndexSearcher for MockSearcher {
78        async fn search(
79            &self,
80            _partitions: &[String],
81            _query: &str,
82            _opts: &SearchOptions,
83        ) -> Result<Vec<ScoredEntry>, StoreError> {
84            Ok(vec![])
85        }
86    }
87
88    fn setup() -> Arc<super::super::default::LayeredMemory<InMemoryEntryStore, MockSearcher>> {
89        Arc::new(super::super::default::LayeredMemory::new(
90            Arc::new(InMemoryEntryStore::new()),
91            Arc::new(MockSearcher),
92        ))
93    }
94
95    #[tokio::test]
96    async fn test_set_and_get_preferences() {
97        let memory = setup();
98
99        memory.set_preference("u1", "lang", "zh-CN").await.unwrap();
100        memory.set_preference("u1", "theme", "dark").await.unwrap();
101
102        let prefs = memory.get_preferences("u1").await.unwrap();
103        assert_eq!(prefs.get("lang").map(String::as_str), Some("zh-CN"));
104        assert_eq!(prefs.get("theme").map(String::as_str), Some("dark"));
105    }
106
107    #[tokio::test]
108    async fn test_overwrite_preference() {
109        let memory = setup();
110
111        memory.set_preference("u1", "lang", "en").await.unwrap();
112        memory.set_preference("u1", "lang", "ja").await.unwrap();
113
114        let prefs = memory.get_preferences("u1").await.unwrap();
115        assert_eq!(prefs.len(), 1);
116        assert_eq!(prefs.get("lang").map(String::as_str), Some("ja"));
117    }
118
119    #[tokio::test]
120    async fn test_remove_preference() {
121        let memory = setup();
122
123        memory.set_preference("u1", "lang", "en").await.unwrap();
124        memory.set_preference("u1", "theme", "dark").await.unwrap();
125        memory.remove_preference("u1", "lang").await.unwrap();
126
127        let prefs = memory.get_preferences("u1").await.unwrap();
128        assert_eq!(prefs.len(), 1);
129        assert!(!prefs.contains_key("lang"));
130        assert_eq!(prefs.get("theme").map(String::as_str), Some("dark"));
131    }
132
133    #[tokio::test]
134    async fn test_remove_nonexistent_is_noop() {
135        let memory = setup();
136        let result = memory.remove_preference("u1", "nonexistent").await;
137        assert!(result.is_ok());
138    }
139
140    #[tokio::test]
141    async fn test_empty_preferences() {
142        let memory = setup();
143        let prefs = memory.get_preferences("nonexistent").await.unwrap();
144        assert!(prefs.is_empty());
145    }
146
147    #[tokio::test]
148    async fn test_isolated_users() {
149        let memory = setup();
150
151        memory.set_preference("u1", "lang", "en").await.unwrap();
152        memory.set_preference("u2", "lang", "fr").await.unwrap();
153
154        let prefs_u1 = memory.get_preferences("u1").await.unwrap();
155        let prefs_u2 = memory.get_preferences("u2").await.unwrap();
156
157        assert_eq!(prefs_u1.get("lang").map(String::as_str), Some("en"));
158        assert_eq!(prefs_u2.get("lang").map(String::as_str), Some("fr"));
159    }
160}