1use crate::{
2 SearchError, SearchIndex,
3 models::{
4 DocumentPage, FileSearchRequest, FileSearchResults, FlattenedItemResult, PageResult,
5 SearchIndexData, SearchRequest, SearchResults, SearchScore, UpdateSearchIndexData,
6 },
7 typesense::{
8 api_key::ApiKeyProvider,
9 models::{
10 GenericSearchResponse, GroupedSearchResponse, SearchResponse, TypesenseDataEntry,
11 TypesenseDataEntryPageV1, TypesenseDataEntryRootV1, TypesenseDataEntryV1,
12 TypesenseEntry,
13 },
14 },
15};
16use docbox_database::{
17 DbTransaction,
18 models::{
19 document_box::{DocumentBoxScopeRaw, DocumentBoxScopeRawRef},
20 file::FileId,
21 folder::FolderId,
22 tenant::Tenant,
23 },
24};
25use docbox_secrets::SecretManager;
26use itertools::Itertools;
27use reqwest::StatusCode;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use std::{fmt::Debug, sync::Arc};
31use uuid::Uuid;
32
33pub use api_key::{TypesenseApiKey, TypesenseApiKeyProvider, TypesenseApiKeySecret};
34pub use error::{TypesenseIndexFactoryError, TypesenseSearchError};
35
36pub mod api_key;
37pub mod error;
38mod models;
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct TypesenseSearchConfig {
42 pub url: String,
43
44 pub api_key: Option<TypesenseApiKey>,
46
47 pub api_key_secret_name: Option<String>,
49}
50
51impl TypesenseSearchConfig {
52 pub fn from_env() -> Result<Self, TypesenseIndexFactoryError> {
53 let url = std::env::var("DOCBOX_TYPESENSE_URL")
54 .or(std::env::var("TYPESENSE_URL"))
55 .map_err(|_| TypesenseIndexFactoryError::MissingUrl)?;
56
57 let api_key = std::env::var("DOCBOX_TYPESENSE_API_KEY")
58 .or(std::env::var("TYPESENSE_API_KEY"))
59 .map(TypesenseApiKey::new)
60 .ok();
61 let api_key_secret_name = std::env::var("DOCBOX_TYPESENSE_API_KEY_SECRET_NAME")
62 .or(std::env::var("TYPESENSE_API_KEY_SECRET_NAME"))
63 .ok();
64
65 Ok(Self {
66 url,
67 api_key,
68 api_key_secret_name,
69 })
70 }
71}
72
73pub struct TypesenseClientData {
75 base_url: String,
76 api_key_provider: TypesenseApiKeyProvider,
77}
78
79#[derive(Clone)]
80pub struct TypesenseIndexFactory {
81 client: reqwest::Client,
82 client_data: Arc<TypesenseClientData>,
83}
84
85impl TypesenseIndexFactory {
86 pub fn from_config(
87 secrets: SecretManager,
88 config: TypesenseSearchConfig,
89 ) -> Result<Self, TypesenseIndexFactoryError> {
90 let api_key_provider = match (config.api_key, config.api_key_secret_name) {
91 (Some(api_key), _) => {
92 tracing::debug!("using typesense api key");
93 TypesenseApiKeyProvider::ApiKey(api_key)
94 }
95 (_, Some(secret_name)) => {
96 tracing::debug!("using secret manager controller typesense api key");
97 TypesenseApiKeyProvider::Secret(TypesenseApiKeySecret::new(secrets, secret_name))
98 }
99 _ => return Err(TypesenseIndexFactoryError::MissingApiKey),
100 };
101
102 let client = reqwest::Client::builder()
103 .no_proxy()
105 .build()
106 .map_err(|error| {
107 tracing::error!(?error, " failed to create typesense http client");
108 TypesenseIndexFactoryError::CreateClient
109 })?;
110
111 let client_data = Arc::new(TypesenseClientData {
112 base_url: config.url,
113 api_key_provider,
114 });
115
116 Ok(Self {
117 client,
118 client_data,
119 })
120 }
121
122 pub fn create_search_index(&self, index: String) -> TypesenseIndex {
123 TypesenseIndex {
124 client: self.client.clone(),
125 client_data: self.client_data.clone(),
126 index,
127 }
128 }
129}
130
131#[derive(Clone)]
132pub struct TypesenseIndex {
133 client: reqwest::Client,
134 client_data: Arc<TypesenseClientData>,
135 index: String,
136}
137
138fn escape_typesense_value(input: &str) -> String {
139 let escaped = input.replace('`', "\\`");
141
142 format!("`{escaped}`")
144}
145
146impl SearchIndex for TypesenseIndex {
147 async fn create_index(&self) -> Result<(), SearchError> {
148 let api_key = self.client_data.api_key_provider.get_api_key().await?;
149
150 let schema = json!({
151 "name": self.index,
152 "fields": [
153 { "name": "id", "type": "string" },
154
155 { "name": "version", "type": "string", "facet": true },
156 { "name": "entry_type", "type": "string", "facet": true },
157
158 { "name": "document_box", "type": "string", "facet": true },
159 { "name": "folder_id", "type": "string", "facet": true },
160
161 { "name": "item_type", "type": "string", "facet": true },
162 { "name": "item_id", "type": "string", "facet": true },
163 { "name": "name", "type": "string" },
164
165 { "name": "value", "type": "string", "optional": true },
166 { "name": "mime", "type": "string", "optional": true },
167
168 { "name": "created_at", "type": "int64", "facet": true },
169 { "name": "created_by", "type": "string", "optional": true, "facet": true },
170
171 { "name": "page", "type": "int32", "optional": true },
172 { "name": "page_content", "type": "string", "optional": true }
173 ]
174 });
175
176 self.client
177 .post(format!("{}/collections", self.client_data.base_url))
178 .header("x-typesense-api-key", api_key)
179 .json(&schema)
180 .send()
181 .await
182 .map_err(|error| {
183 tracing::error!(?error, "failed to create search index (io)");
184 TypesenseSearchError::CreateIndex
185 })?
186 .error_for_status()
187 .map_err(|error| {
188 tracing::error!(?error, "failed to create search index (response)");
189 TypesenseSearchError::CreateIndex
190 })?;
191
192 Ok(())
193 }
194
195 async fn index_exists(&self) -> Result<bool, SearchError> {
196 let api_key = self.client_data.api_key_provider.get_api_key().await?;
197
198 let response = self
199 .client
200 .get(format!(
201 "{}/collections/{}",
202 self.client_data.base_url, self.index
203 ))
204 .header("x-typesense-api-key", api_key)
205 .send()
206 .await
207 .map_err(|error| {
208 tracing::error!(?error, "failed to get search index (io)");
209 TypesenseSearchError::GetIndex
210 })?;
211
212 if response.status() == StatusCode::NOT_FOUND {
213 return Ok(false);
214 }
215
216 response.error_for_status().map_err(|error| {
217 tracing::error!(?error, "failed to get search index (response)");
218 TypesenseSearchError::GetIndex
219 })?;
220
221 Ok(true)
222 }
223
224 async fn delete_index(&self) -> Result<(), SearchError> {
225 let api_key = self.client_data.api_key_provider.get_api_key().await?;
226
227 let response = self
228 .client
229 .delete(format!(
230 "{}/collections/{}",
231 self.client_data.base_url, self.index
232 ))
233 .header("x-typesense-api-key", api_key)
234 .send()
235 .await
236 .map_err(|error| {
237 tracing::error!(?error, "failed to delete search index (io)");
238 TypesenseSearchError::DeleteIndex
239 })?;
240
241 if response.status() == StatusCode::NOT_FOUND {
243 return Ok(());
244 }
245
246 response.error_for_status().map_err(|error| {
247 tracing::error!(?error, "failed to delete search index (response)");
248 TypesenseSearchError::DeleteIndex
249 })?;
250
251 Ok(())
252 }
253
254 async fn search_index_file(
255 &self,
256 scope: &DocumentBoxScopeRaw,
257 file_id: FileId,
258 query: FileSearchRequest,
259 ) -> Result<FileSearchResults, SearchError> {
260 let api_key = self.client_data.api_key_provider.get_api_key().await?;
261
262 let offset = query.offset.unwrap_or(0);
263 let limit = query.limit.unwrap_or(50);
264 let query = query.query.unwrap_or_default();
265 let filter_by = format!(
266 r#"document_box:="{}"&&item_id:="{}"&&entry_type:="Page""#,
267 escape_typesense_value(scope),
268 &file_id
270 );
271
272 let query_json = json!({
273 "searches": [
274 {
275 "collection": self.index,
276 "q": query,
277 "query_by": "page_content",
278 "offset": offset,
279 "limit": limit,
280 "filter_by": filter_by,
281 "exclude_fields": "page_content",
282 "highlight_fields": "page_content",
283 "highlight_start_tag": "<em>",
284 "highlight_end_tag": "</em>",
285 "highlight_affix_num_tokens": 15,
286 }
287 ]
288 });
289
290 let response = self
291 .client
292 .post(format!("{}/multi_search", self.client_data.base_url))
293 .header("x-typesense-api-key", api_key)
294 .json(&query_json)
295 .send()
296 .await
297 .map_err(|error| {
298 tracing::error!(?error, "failed to query typesense multi_search");
299 TypesenseSearchError::SearchIndex
300 })?;
301
302 if let Err(error) = response.error_for_status_ref() {
303 let body = response.text().await;
304 tracing::error!(?error, ?body, "failed to get search results");
305 return Err(TypesenseSearchError::SearchIndex.into());
306 }
307
308 let search: SearchResponse<GenericSearchResponse> =
309 response.json().await.map_err(|error| {
310 tracing::error!(?error, "failed to parse search response JSON");
311 TypesenseSearchError::SearchIndex
312 })?;
313
314 let search = search
315 .results
316 .into_iter()
317 .next()
318 .ok_or(TypesenseSearchError::MissingSearchResult)?;
319
320 let total_hits = search.found;
321 let results: Vec<PageResult> = search
322 .hits
323 .into_iter()
324 .filter_map(|hit| match hit.document {
325 TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(page)) => {
326 let highlighted = hit
327 .highlights
328 .into_iter()
329 .find(|value| value.field == "page_content")
330 .map(|value| value.snippet)?;
331
332 Some(PageResult {
333 page: page.page,
334 matches: vec![highlighted],
335 })
336 }
337 _ => None,
338 })
339 .collect();
340
341 Ok(FileSearchResults {
342 total_hits,
343 results,
344 })
345 }
346
347 async fn search_index(
348 &self,
349 scopes: &[DocumentBoxScopeRaw],
350 query: SearchRequest,
351 folder_children: Option<Vec<FolderId>>,
352 ) -> Result<SearchResults, SearchError> {
353 let mut query_by = Vec::new();
354
355 if query.include_name {
357 query_by.push("name");
358 }
359
360 if query.include_content {
362 query_by.push("value");
363 query_by.push("page_content");
364 }
365
366 let filter_by = create_search_filters(scopes, &query, folder_children);
367 let search_query = query.query.unwrap_or_default();
368
369 if query_by.is_empty() {
371 if !search_query.is_empty() && !filter_by.is_empty() {
373 return Err(TypesenseSearchError::MissingQueryBy.into());
374 }
375
376 query_by.push("name");
378 }
379
380 let has_wildcard = scopes.iter().any(|scope| scope.ends_with('*'));
381
382 let max_filter_by_candidates = if has_wildcard {
383 10_000
385 } else {
386 4
387 };
388
389 let size = query.size.unwrap_or(50);
390 let offset = query.offset.unwrap_or(0);
391
392 let max_pages = query.max_pages.unwrap_or(3);
393
394 let query_by = query_by.join(",");
395
396 let query_json = json!({
397 "searches": [
398 {
399 "collection": self.index,
400 "q": search_query,
401 "query_by": query_by,
402 "group_by": "item_id",
403 "group_limit": max_pages,
404 "offset": offset,
405 "limit": size,
406 "filter_by": filter_by,
407 "exclude_fields": "page_content",
408 "highlight_fields": "name,value,page_content",
409 "highlight_start_tag": "<em>",
410 "highlight_end_tag": "</em>",
411 "max_filter_by_candidates": max_filter_by_candidates
412 }
413 ]
414 });
415
416 tracing::debug!(?query_json, "performing search query");
417
418 let api_key = self.client_data.api_key_provider.get_api_key().await?;
419
420 let response = self
421 .client
422 .post(format!("{}/multi_search", self.client_data.base_url))
423 .header("x-typesense-api-key", api_key)
424 .json(&query_json)
425 .send()
426 .await
427 .map_err(|error| {
428 tracing::error!(?error, "failed to query typesense multi_search");
429 TypesenseSearchError::SearchIndex
430 })?;
431
432 if let Err(error) = response.error_for_status_ref() {
433 let body = response.text().await;
434 let query = serde_json::to_string(&query_json);
435 tracing::error!(?error, ?body, ?query, "failed to get search results");
436 return Err(TypesenseSearchError::SearchIndex.into());
437 }
438
439 let response: serde_json::Value = response.json().await.map_err(|error| {
440 tracing::error!(?error, "failed to parse search response JSON");
441 TypesenseSearchError::SearchIndex
442 })?;
443 tracing::debug!(?response);
444
445 let search: SearchResponse<GroupedSearchResponse> = serde_json::from_value(response)
446 .map_err(|error| {
447 tracing::error!(?error, "failed to parse search response JSON");
448 TypesenseSearchError::SearchIndex
449 })?;
450 let search = search
451 .results
452 .into_iter()
453 .next()
454 .ok_or(TypesenseSearchError::MissingSearchResult)?;
455
456 let total_hits = search.found;
457
458 let results = search
459 .grouped_hits
460 .into_iter()
461 .filter_map(|group| {
462 let root = group.hits.first().map(|value| &value.document)?;
463
464 match root {
465 TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root))
466 | TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(
467 TypesenseDataEntryPageV1 { root, .. },
468 )) => {
469 let group_score = group
470 .hits
471 .iter()
472 .map(|hit| hit.text_match)
473 .max()
474 .unwrap_or_default();
475
476 let name_match = group.hits.iter().any(|hit| {
478 hit.highlights
479 .iter()
480 .any(|highlight| highlight.field == "name")
481 });
482
483 let content_match = group.hits.iter().any(|hit| {
485 hit.highlights.iter().any(|highlight| {
486 highlight.field == "value" || highlight.field == "page_content"
487 })
488 });
489
490 let page_matches: Vec<PageResult> = group
491 .hits
492 .iter()
493 .filter_map(|hit| match &hit.document {
494 TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(page)) => {
495 let highlighted = hit
496 .highlights
497 .iter()
498 .find(|value| value.field == "page_content")
499 .map(|value| value.snippet.to_string())?;
500
501 Some(PageResult {
502 page: page.page,
503 matches: vec![highlighted],
504 })
505 }
506 _ => None,
507 })
508 .collect();
509
510 Some(FlattenedItemResult {
511 item_ty: root.ty,
512 item_id: root.item_id,
513 document_box: root.document_box.clone(),
514 page_matches,
515 total_hits: group.found,
516 score: SearchScore::Integer(group_score),
517 name_match,
518 content_match,
519 })
520 }
521 }
522 })
523 .collect();
524
525 Ok(SearchResults {
526 total_hits,
527 results,
528 })
529 }
530
531 async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
532 let mut documents = Vec::new();
533
534 for data in data {
535 let root = TypesenseDataEntryRootV1 {
536 document_box: data.document_box,
537 folder_id: data.folder_id,
538 ty: data.ty,
539 item_id: data.item_id,
540 created_at: data.created_at.timestamp(),
541 created_by: data.created_by,
542 value: data.content,
543 mime: data.mime,
544 name: data.name,
545 };
546
547 if let Some(pages) = data.pages {
549 for page in pages {
551 documents.push(create_item_page(&root, page));
552 }
553 }
554
555 documents.push(TypesenseEntry {
557 id: Uuid::new_v4(),
558 entry: TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root)),
559 });
560 }
561
562 self.bulk_add_documents(documents).await?;
563
564 Ok(())
565 }
566
567 async fn update_data(
568 &self,
569 item_id: Uuid,
570 data: UpdateSearchIndexData,
571 ) -> Result<(), SearchError> {
572 self.update_item_roots(item_id, &data).await?;
574
575 if let Some(pages) = data.pages {
576 self.delete_item_pages(item_id).await?;
578
579 let root = self
581 .get_item_root(item_id)
582 .await?
583 .ok_or(TypesenseSearchError::MissingRootEntry)?;
584
585 let updated_root = TypesenseDataEntryRootV1 {
588 document_box: root.document_box.clone(),
589 folder_id: data.folder_id,
590 ty: root.ty,
591 item_id: root.item_id,
592 name: data.name.clone(),
593 value: data.content.clone(),
594 mime: root.mime.clone(),
595 created_at: root.created_at,
596 created_by: root.created_by.clone(),
597 };
598
599 let documents = pages
601 .into_iter()
602 .map(|page| create_item_page(&updated_root, page))
603 .collect();
604
605 self.bulk_add_documents(documents).await?;
607 }
608
609 Ok(())
610 }
611
612 async fn delete_data(&self, id: Uuid) -> Result<(), SearchError> {
613 let api_key = self.client_data.api_key_provider.get_api_key().await?;
614
615 self.client
616 .delete(format!(
617 "{}/collections/{}/documents",
618 self.client_data.base_url, self.index
619 ))
620 .header("x-typesense-api-key", api_key)
621 .query(&[("filter_by", format!(r#"item_id:="{id}""#))])
622 .send()
623 .await
624 .map_err(|error| {
625 tracing::error!(?error, "failed to delete data (request)");
626 TypesenseSearchError::DeleteDocuments
627 })?
628 .error_for_status()
629 .map_err(|error| {
630 tracing::error!(?error, "failed to delete data (response)");
631 TypesenseSearchError::DeleteDocuments
632 })?;
633 Ok(())
634 }
635
636 async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError> {
637 let api_key = self.client_data.api_key_provider.get_api_key().await?;
638
639 self.client
640 .delete(format!(
641 "{}/collections/{}/documents",
642 self.client_data.base_url, self.index
643 ))
644 .header("x-typesense-api-key", api_key)
645 .query(&[("filter_by", format!(r#"document_box:="{scope}""#))])
646 .send()
647 .await
648 .map_err(|error| {
649 tracing::error!(?error, "failed to delete data by scope (request)");
650 TypesenseSearchError::DeleteDocuments
651 })?
652 .error_for_status()
653 .map_err(|error| {
654 tracing::error!(?error, "failed to delete data by scope (response)");
655 TypesenseSearchError::DeleteDocuments
656 })?;
657 Ok(())
658 }
659
660 async fn get_pending_migrations(
661 &self,
662 _applied_names: Vec<String>,
663 ) -> Result<Vec<String>, SearchError> {
664 Ok(Vec::new())
665 }
666
667 async fn apply_migration(
668 &self,
669 _tenant: &Tenant,
670 _root_t: &mut DbTransaction<'_>,
671 _t: &mut DbTransaction<'_>,
672 _name: &str,
673 ) -> Result<(), SearchError> {
674 Ok(())
675 }
676}
677
678impl TypesenseIndex {
679 async fn bulk_add_documents(
681 &self,
682 entries: Vec<TypesenseEntry>,
683 ) -> Result<(), TypesenseSearchError> {
684 let mut bulk_data = String::new();
686 for document in entries {
687 let value = serde_json::to_string(&document).map_err(|error| {
688 tracing::error!(?error, "failed to serialize a document");
689 TypesenseSearchError::BulkAddDocuments
690 })?;
691 bulk_data.push_str(&value);
692 bulk_data.push('\n');
693 }
694
695 let api_key = self.client_data.api_key_provider.get_api_key().await?;
696
697 self.client
698 .post(format!(
699 "{}/collections/{}/documents/import",
700 self.client_data.base_url, self.index
701 ))
702 .header("x-typesense-api-key", api_key)
703 .body(bulk_data)
704 .send()
705 .await
706 .map_err(|error| {
707 tracing::error!(?error, "failed to send import documents (request)");
708 TypesenseSearchError::BulkAddDocuments
709 })?
710 .error_for_status()
711 .map_err(|error| {
712 tracing::error!(?error, "error status when importing documents (response)");
713 TypesenseSearchError::BulkAddDocuments
714 })?;
715
716 Ok(())
717 }
718
719 async fn delete_item_pages(&self, item_id: Uuid) -> Result<(), TypesenseSearchError> {
721 let api_key = self.client_data.api_key_provider.get_api_key().await?;
722
723 self.client
724 .delete(format!(
725 "{}/collections/{}/documents",
726 self.client_data.base_url, self.index
727 ))
728 .header("x-typesense-api-key", api_key)
729 .query(&[(
730 "filter_by",
731 format!(r#"item_id:="{item_id}"&&entry_type="Page""#),
732 )])
733 .send()
734 .await
735 .map_err(|error| {
736 tracing::error!(?error, "failed to send delete documents (request)");
737 TypesenseSearchError::DeleteDocuments
738 })?
739 .error_for_status()
740 .map_err(|error| {
741 tracing::error!(?error, "failed to send delete documents (response)");
742 TypesenseSearchError::DeleteDocuments
743 })?;
744
745 Ok(())
746 }
747
748 async fn update_item_roots(
750 &self,
751 item_id: Uuid,
752 update: &UpdateSearchIndexData,
753 ) -> Result<(), TypesenseSearchError> {
754 let api_key = self.client_data.api_key_provider.get_api_key().await?;
755
756 let request = json!({
757 "folder_id": update.folder_id,
758 "name": update.name,
759 "value": update.content,
760 });
761
762 self.client
764 .patch(format!(
765 "{}/collections/{}/documents",
766 self.client_data.base_url, self.index
767 ))
768 .header("x-typesense-api-key", api_key)
769 .query(&[("filter_by", format!(r#"item_id:="{item_id}""#))])
770 .json(&request)
771 .send()
772 .await
773 .map_err(|error| {
774 tracing::error!(?error, "failed to send update documents (request)");
775 TypesenseSearchError::UpdateDocument
776 })?
777 .error_for_status()
778 .map_err(|error| {
779 tracing::error!(?error, "failed to send update documents (response)");
780 TypesenseSearchError::UpdateDocument
781 })?;
782
783 Ok(())
784 }
785
786 async fn get_item_root(
788 &self,
789 item_id: Uuid,
790 ) -> Result<Option<TypesenseDataEntryRootV1>, TypesenseSearchError> {
791 let api_key = self.client_data.api_key_provider.get_api_key().await?;
792
793 let response: GenericSearchResponse = self
794 .client
795 .get(format!(
796 "{}/collections/{}/documents/search",
797 self.client_data.base_url, self.index
798 ))
799 .header("x-typesense-api-key", api_key)
800 .query(&[(
801 "filter_by",
802 format!(r#"item_id:="{item_id}"&&entry_type=Root"#),
803 )])
804 .send()
805 .await
806 .map_err(|error| {
807 tracing::error!(?error, "failed to query documents search (request)");
808 TypesenseSearchError::GetDocument
809 })?
810 .error_for_status()
811 .map_err(|error| {
812 tracing::error!(?error, "failed to query documents search (response)");
813 TypesenseSearchError::GetDocument
814 })?
815 .json()
816 .await
817 .map_err(|error| {
818 tracing::error!(?error, "failed to query documents search (response json)");
819 TypesenseSearchError::GetDocument
820 })?;
821
822 let item = response
823 .hits
824 .into_iter()
825 .filter_map(|hit| match hit.document {
826 TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root)) => Some(root),
827 _ => None,
828 })
829 .next();
830
831 Ok(item)
832 }
833}
834
835fn create_item_page(root: &TypesenseDataEntryRootV1, page: DocumentPage) -> TypesenseEntry {
836 TypesenseEntry {
837 id: Uuid::new_v4(),
838 entry: TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(TypesenseDataEntryPageV1 {
839 root: root.clone(),
840 page: page.page,
841 page_content: Some(page.content),
842 })),
843 }
844}
845
846fn create_search_filters(
848 scopes: &[DocumentBoxScopeRaw],
849 query: &SearchRequest,
850 folder_children: Option<Vec<FolderId>>,
851) -> String {
852 let mut filter_parts = Vec::new();
853
854 {
856 let scopes = scopes
857 .iter()
858 .map(|value| escape_typesense_value(value))
859 .join(", ");
860
861 filter_parts.push(format!("document_box:=[{scopes}]"));
862 }
863
864 if let Some(folder_children) = folder_children
866 && !folder_children.is_empty()
867 {
868 let ids = folder_children
869 .into_iter()
870 .map(|value| value.to_string())
872 .join(", ");
873
874 filter_parts.push(format!("folder_id:=[{ids}]"));
875 }
876
877 if let Some(range) = query.created_at.as_ref() {
878 if let Some(start) = range.start {
879 let start = start.timestamp();
880 filter_parts.push(format!(r#"created_at:>{start}"#));
881 }
882
883 if let Some(end) = range.end {
884 let end = end.timestamp();
885 filter_parts.push(format!(r#"created_at:<{end}"#));
886 }
887 }
888
889 if let Some(created_by) = query.created_by.as_ref() {
890 filter_parts.push(format!(
891 r#"created_by:="{}""#,
892 escape_typesense_value(created_by)
894 ));
895 }
896
897 if let Some(folder_id) = query.folder_id {
898 filter_parts.push(format!(r#"folder_id:="{folder_id}""#));
899 }
900
901 filter_parts.join("&&")
902}