1use std::collections::BTreeMap;
14use std::sync::RwLock;
15
16use autumn_web::search::{IndexDefinition, SearchDocument, SearchIndexed};
17
18use crate::backend::BoxFuture;
19use crate::error::SearchResult;
20
21pub trait DocumentSource: Send + Sync + 'static {
24 fn fetch<'a>(
30 &'a self,
31 definition: &'a IndexDefinition,
32 ids: &'a [i64],
33 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
34
35 fn scan<'a>(
41 &'a self,
42 definition: &'a IndexDefinition,
43 after: Option<i64>,
44 limit: usize,
45 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
46}
47
48#[derive(Debug, Default)]
54pub struct MemoryDocumentSource {
55 documents: RwLock<BTreeMap<(String, i64), SearchDocument>>,
56 fetches: RwLock<BTreeMap<i64, usize>>,
57}
58
59impl MemoryDocumentSource {
60 #[must_use]
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn upsert<M: SearchIndexed>(&self, record: &M) {
68 self.upsert_document(record.search_document());
69 }
70
71 pub fn upsert_document(&self, document: SearchDocument) {
73 let mut guard = self
77 .documents
78 .write()
79 .unwrap_or_else(std::sync::PoisonError::into_inner);
80 guard.insert((document.index.to_owned(), document.id), document);
81 }
82
83 pub fn remove(&self, id: i64) {
85 self.documents
86 .write()
87 .unwrap_or_else(std::sync::PoisonError::into_inner)
88 .retain(|(_, key), _| *key != id);
89 }
90
91 #[must_use]
93 pub fn fetch_calls_for(&self, id: i64) -> usize {
94 self.fetches
95 .read()
96 .unwrap_or_else(std::sync::PoisonError::into_inner)
97 .get(&id)
98 .copied()
99 .unwrap_or(0)
100 }
101
102 fn record_fetch(&self, ids: &[i64]) {
103 let mut guard = self
104 .fetches
105 .write()
106 .unwrap_or_else(std::sync::PoisonError::into_inner);
107 for id in ids {
108 guard
109 .entry(*id)
110 .and_modify(|count| *count = count.saturating_add(1))
111 .or_insert(1);
112 }
113 }
114}
115
116impl DocumentSource for MemoryDocumentSource {
117 fn fetch<'a>(
118 &'a self,
119 definition: &'a IndexDefinition,
120 ids: &'a [i64],
121 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
122 Box::pin(async move {
123 self.record_fetch(ids);
124 let guard = self
125 .documents
126 .read()
127 .unwrap_or_else(std::sync::PoisonError::into_inner);
128 Ok(ids
129 .iter()
130 .filter_map(|id| guard.get(&(definition.name.to_owned(), *id)).cloned())
131 .collect())
132 })
133 }
134
135 fn scan<'a>(
136 &'a self,
137 definition: &'a IndexDefinition,
138 after: Option<i64>,
139 limit: usize,
140 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
141 Box::pin(async move {
142 let guard = self
143 .documents
144 .read()
145 .unwrap_or_else(std::sync::PoisonError::into_inner);
146 Ok(guard
147 .iter()
148 .filter(|((index, id), _)| {
149 index == definition.name && after.is_none_or(|after| *id > after)
150 })
151 .take(limit)
152 .map(|(_, document)| document.clone())
153 .collect())
154 })
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use autumn_web::search::SearchIndexField;
161
162 use super::*;
163
164 const FIELDS: &[SearchIndexField] = &[SearchIndexField::new("title", 'A')];
165
166 fn definition() -> IndexDefinition {
167 IndexDefinition::new("articles", "simple", FIELDS, None, false)
168 }
169
170 fn document(id: i64) -> SearchDocument {
171 SearchDocument::new("articles", id).with_field("title", 'A', format!("record {id}"))
172 }
173
174 #[tokio::test]
175 async fn fetching_a_missing_id_is_an_absence_not_an_error() {
176 let source = MemoryDocumentSource::new();
177 source.upsert_document(document(1));
178
179 let found = source
180 .fetch(&definition(), &[1, 2])
181 .await
182 .expect("fetch must not error on a missing row");
183 assert_eq!(found.len(), 1);
184 assert_eq!(found[0].id, 1);
185 }
186
187 #[tokio::test]
188 async fn scanning_walks_ids_in_order_via_keyset_pagination() {
189 let source = MemoryDocumentSource::new();
190 for id in 1..=5 {
191 source.upsert_document(document(id));
192 }
193
194 let first = source.scan(&definition(), None, 2).await.expect("scan");
195 assert_eq!(first.iter().map(|d| d.id).collect::<Vec<_>>(), vec![1, 2]);
196
197 let second = source.scan(&definition(), Some(2), 2).await.expect("scan");
198 assert_eq!(second.iter().map(|d| d.id).collect::<Vec<_>>(), vec![3, 4]);
199
200 let last = source.scan(&definition(), Some(5), 2).await.expect("scan");
201 assert!(last.is_empty());
202 }
203
204 #[tokio::test]
205 async fn scanning_is_scoped_to_one_index() {
206 let source = MemoryDocumentSource::new();
207 source.upsert_document(document(1));
208 source.upsert_document(SearchDocument::new("notes", 1).with_field("title", 'A', "note"));
209
210 let articles = source.scan(&definition(), None, 10).await.expect("scan");
211 assert_eq!(articles.len(), 1);
212 assert_eq!(articles[0].index, "articles");
213 }
214
215 #[tokio::test]
216 async fn fetch_calls_are_counted_per_id() {
217 let source = MemoryDocumentSource::new();
218 source.upsert_document(document(1));
219 assert_eq!(source.fetch_calls_for(1), 0);
220 source.fetch(&definition(), &[1]).await.expect("fetch");
221 source.fetch(&definition(), &[1]).await.expect("fetch");
222 assert_eq!(source.fetch_calls_for(1), 2);
223 assert_eq!(source.fetch_calls_for(9), 0);
224 }
225}