xz-memory-engine 0.2.0

Reusable engine implementations for xz-memory-core: storage backends and layered memory
Documentation
//! [`UserProfileMemory`] implementation for [`LayeredMemory`].
//!
//! Stores per-user preferences as individual entries in the
//! `user:{user_id}:profile` partition.  Each entry's `id` is the preference
//! key and its `body` is the preference value.

use std::collections::HashMap;

use async_trait::async_trait;
use chrono::Utc;
use xz_memory_core::{Entry, EntryStore, IndexSearcher, StoreError};

use super::traits::UserProfileMemory;

impl<S: EntryStore, I: IndexSearcher> super::default::LayeredMemory<S, I> {
    fn user_profile_partition(user_id: &str) -> String {
        format!("user:{}:profile", user_id)
    }
}

#[async_trait]
impl<S: EntryStore, I: IndexSearcher> UserProfileMemory for super::default::LayeredMemory<S, I> {
    async fn get_preferences(&self, user_id: &str) -> Result<HashMap<String, String>, StoreError> {
        let partition = Self::user_profile_partition(user_id);
        let prefix = format!("{}:", user_id);
        let opts = xz_memory_core::QueryOptions {
            limit: usize::MAX,
            sort: xz_memory_core::SortOrder::Ascending,
        };
        let range = xz_memory_core::TimeRange { start: None, end: None };
        let entries = self.store.query(&partition, &range, &opts).await?;
        let mut map = HashMap::new();
        for e in entries {
            if let Some(key) = e.id.strip_prefix(&prefix) {
                map.insert(key.to_string(), e.body);
            }
        }
        Ok(map)
    }

    async fn set_preference(
        &self,
        user_id: &str,
        key: &str,
        value: &str,
    ) -> Result<(), StoreError> {
        let partition = Self::user_profile_partition(user_id);
        let entry_id = format!("{}:{}", user_id, key);
        let _ = self.store.delete(&entry_id).await;
        let entry = Entry {
            id: entry_id,
            partition,
            body: value.to_string(),
            recorded_at: Utc::now().timestamp_millis() as u64,
        };
        self.store.append(entry).await
    }

    async fn remove_preference(&self, user_id: &str, key: &str) -> Result<(), StoreError> {
        let entry_id = format!("{}:{}", user_id, key);
        let _ = self.store.delete(&entry_id).await;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use xz_memory_core::{ScoredEntry, SearchOptions};

    use crate::backends::InMemoryEntryStore;

    struct MockSearcher;

    #[async_trait]
    impl IndexSearcher for MockSearcher {
        async fn search(
            &self,
            _partitions: &[String],
            _query: &str,
            _opts: &SearchOptions,
        ) -> Result<Vec<ScoredEntry>, StoreError> {
            Ok(vec![])
        }
    }

    fn setup() -> Arc<super::super::default::LayeredMemory<InMemoryEntryStore, MockSearcher>> {
        Arc::new(super::super::default::LayeredMemory::new(
            Arc::new(InMemoryEntryStore::new()),
            Arc::new(MockSearcher),
        ))
    }

    #[tokio::test]
    async fn test_set_and_get_preferences() {
        let memory = setup();

        memory.set_preference("u1", "lang", "zh-CN").await.unwrap();
        memory.set_preference("u1", "theme", "dark").await.unwrap();

        let prefs = memory.get_preferences("u1").await.unwrap();
        assert_eq!(prefs.get("lang").map(String::as_str), Some("zh-CN"));
        assert_eq!(prefs.get("theme").map(String::as_str), Some("dark"));
    }

    #[tokio::test]
    async fn test_overwrite_preference() {
        let memory = setup();

        memory.set_preference("u1", "lang", "en").await.unwrap();
        memory.set_preference("u1", "lang", "ja").await.unwrap();

        let prefs = memory.get_preferences("u1").await.unwrap();
        assert_eq!(prefs.len(), 1);
        assert_eq!(prefs.get("lang").map(String::as_str), Some("ja"));
    }

    #[tokio::test]
    async fn test_remove_preference() {
        let memory = setup();

        memory.set_preference("u1", "lang", "en").await.unwrap();
        memory.set_preference("u1", "theme", "dark").await.unwrap();
        memory.remove_preference("u1", "lang").await.unwrap();

        let prefs = memory.get_preferences("u1").await.unwrap();
        assert_eq!(prefs.len(), 1);
        assert!(!prefs.contains_key("lang"));
        assert_eq!(prefs.get("theme").map(String::as_str), Some("dark"));
    }

    #[tokio::test]
    async fn test_remove_nonexistent_is_noop() {
        let memory = setup();
        let result = memory.remove_preference("u1", "nonexistent").await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_empty_preferences() {
        let memory = setup();
        let prefs = memory.get_preferences("nonexistent").await.unwrap();
        assert!(prefs.is_empty());
    }

    #[tokio::test]
    async fn test_isolated_users() {
        let memory = setup();

        memory.set_preference("u1", "lang", "en").await.unwrap();
        memory.set_preference("u2", "lang", "fr").await.unwrap();

        let prefs_u1 = memory.get_preferences("u1").await.unwrap();
        let prefs_u2 = memory.get_preferences("u2").await.unwrap();

        assert_eq!(prefs_u1.get("lang").map(String::as_str), Some("en"));
        assert_eq!(prefs_u2.get("lang").map(String::as_str), Some("fr"));
    }
}