use std::collections::BTreeMap;
use std::sync::RwLock;
use autumn_web::search::{IndexDefinition, SearchDocument, SearchIndexed};
use crate::backend::BoxFuture;
use crate::error::SearchResult;
pub trait DocumentSource: Send + Sync + 'static {
fn fetch<'a>(
&'a self,
definition: &'a IndexDefinition,
ids: &'a [i64],
) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
fn scan<'a>(
&'a self,
definition: &'a IndexDefinition,
after: Option<i64>,
limit: usize,
) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
}
#[derive(Debug, Default)]
pub struct MemoryDocumentSource {
documents: RwLock<BTreeMap<(String, i64), SearchDocument>>,
fetches: RwLock<BTreeMap<i64, usize>>,
}
impl MemoryDocumentSource {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn upsert<M: SearchIndexed>(&self, record: &M) {
self.upsert_document(record.search_document());
}
pub fn upsert_document(&self, document: SearchDocument) {
let mut guard = self
.documents
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.insert((document.index.to_owned(), document.id), document);
}
pub fn remove(&self, id: i64) {
self.documents
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|(_, key), _| *key != id);
}
#[must_use]
pub fn fetch_calls_for(&self, id: i64) -> usize {
self.fetches
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&id)
.copied()
.unwrap_or(0)
}
fn record_fetch(&self, ids: &[i64]) {
let mut guard = self
.fetches
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for id in ids {
guard
.entry(*id)
.and_modify(|count| *count = count.saturating_add(1))
.or_insert(1);
}
}
}
impl DocumentSource for MemoryDocumentSource {
fn fetch<'a>(
&'a self,
definition: &'a IndexDefinition,
ids: &'a [i64],
) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
Box::pin(async move {
self.record_fetch(ids);
let guard = self
.documents
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(ids
.iter()
.filter_map(|id| guard.get(&(definition.name.to_owned(), *id)).cloned())
.collect())
})
}
fn scan<'a>(
&'a self,
definition: &'a IndexDefinition,
after: Option<i64>,
limit: usize,
) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
Box::pin(async move {
let guard = self
.documents
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(guard
.iter()
.filter(|((index, id), _)| {
index == definition.name && after.is_none_or(|after| *id > after)
})
.take(limit)
.map(|(_, document)| document.clone())
.collect())
})
}
}
#[cfg(test)]
mod tests {
use autumn_web::search::SearchIndexField;
use super::*;
const FIELDS: &[SearchIndexField] = &[SearchIndexField::new("title", 'A')];
fn definition() -> IndexDefinition {
IndexDefinition::new("articles", "simple", FIELDS, None, false)
}
fn document(id: i64) -> SearchDocument {
SearchDocument::new("articles", id).with_field("title", 'A', format!("record {id}"))
}
#[tokio::test]
async fn fetching_a_missing_id_is_an_absence_not_an_error() {
let source = MemoryDocumentSource::new();
source.upsert_document(document(1));
let found = source
.fetch(&definition(), &[1, 2])
.await
.expect("fetch must not error on a missing row");
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, 1);
}
#[tokio::test]
async fn scanning_walks_ids_in_order_via_keyset_pagination() {
let source = MemoryDocumentSource::new();
for id in 1..=5 {
source.upsert_document(document(id));
}
let first = source.scan(&definition(), None, 2).await.expect("scan");
assert_eq!(first.iter().map(|d| d.id).collect::<Vec<_>>(), vec![1, 2]);
let second = source.scan(&definition(), Some(2), 2).await.expect("scan");
assert_eq!(second.iter().map(|d| d.id).collect::<Vec<_>>(), vec![3, 4]);
let last = source.scan(&definition(), Some(5), 2).await.expect("scan");
assert!(last.is_empty());
}
#[tokio::test]
async fn scanning_is_scoped_to_one_index() {
let source = MemoryDocumentSource::new();
source.upsert_document(document(1));
source.upsert_document(SearchDocument::new("notes", 1).with_field("title", 'A', "note"));
let articles = source.scan(&definition(), None, 10).await.expect("scan");
assert_eq!(articles.len(), 1);
assert_eq!(articles[0].index, "articles");
}
#[tokio::test]
async fn fetch_calls_are_counted_per_id() {
let source = MemoryDocumentSource::new();
source.upsert_document(document(1));
assert_eq!(source.fetch_calls_for(1), 0);
source.fetch(&definition(), &[1]).await.expect("fetch");
source.fetch(&definition(), &[1]).await.expect("fetch");
assert_eq!(source.fetch_calls_for(1), 2);
assert_eq!(source.fetch_calls_for(9), 0);
}
}