helios_persistence/core/search.rs
1//! Search provider traits.
2//!
3//! This module defines a hierarchy of search provider traits:
4//! - [`SearchProvider`] - Basic single-type search
5//! - [`MultiTypeSearchProvider`] - Search across multiple resource types
6//! - [`IncludeProvider`] - Support for _include
7//! - [`RevincludeProvider`] - Support for _revinclude
8//! - [`ChainedSearchProvider`] - Chained parameters and _has
9//! - [`TerminologySearchProvider`] - :above, :below, :in, :not-in
10//! - [`TextSearchProvider`] - Full-text search (_text, _content, :text)
11
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use parking_lot::RwLock;
17
18use crate::error::StorageResult;
19use crate::search::{IndexValue, SearchParameterExtractor, SearchParameterRegistry};
20use crate::tenant::TenantContext;
21use crate::types::{
22 IncludeDirective, IncludeType, Page, ReverseChainedParameter, SearchBundle, SearchParamType,
23 SearchParameter, SearchQuery, SearchValue, StoredResource,
24};
25
26use super::storage::ResourceStorage;
27
28/// Result of a search operation.
29#[derive(Debug, Clone)]
30pub struct SearchResult {
31 /// The matching resources.
32 pub resources: Page<StoredResource>,
33
34 /// Included resources (from _include/_revinclude).
35 pub included: Vec<StoredResource>,
36
37 /// Total count of matches (if requested via _total).
38 pub total: Option<u64>,
39
40 /// Relevance scores (`Bundle.entry.search.score`) for matched resources,
41 /// keyed by resource URL (`Type/id`). Populated by backends that compute
42 /// relevance (e.g. Elasticsearch full-text search); empty otherwise.
43 pub scores: HashMap<String, f64>,
44}
45
46impl SearchResult {
47 /// Creates a new search result.
48 pub fn new(resources: Page<StoredResource>) -> Self {
49 Self {
50 resources,
51 included: Vec::new(),
52 total: None,
53 scores: HashMap::new(),
54 }
55 }
56
57 /// Adds included resources.
58 pub fn with_included(mut self, included: Vec<StoredResource>) -> Self {
59 self.included = included;
60 self
61 }
62
63 /// Sets the total count.
64 pub fn with_total(mut self, total: u64) -> Self {
65 self.total = Some(total);
66 self
67 }
68
69 /// Sets the relevance scores, keyed by resource URL (`Type/id`).
70 pub fn with_scores(mut self, scores: HashMap<String, f64>) -> Self {
71 self.scores = scores;
72 self
73 }
74
75 /// Returns the number of matching resources in this page.
76 pub fn len(&self) -> usize {
77 self.resources.len()
78 }
79
80 /// Returns true if there are no matching resources.
81 pub fn is_empty(&self) -> bool {
82 self.resources.is_empty()
83 }
84
85 /// Returns the cursor for the next page, if there is one.
86 pub fn next_cursor(&self) -> Option<&String> {
87 self.resources.page_info.next_cursor.as_ref()
88 }
89
90 /// Returns the cursor for the previous page, if there is one.
91 pub fn previous_cursor(&self) -> Option<&String> {
92 self.resources.page_info.previous_cursor.as_ref()
93 }
94
95 /// Returns whether there are more results after this page.
96 pub fn has_next(&self) -> bool {
97 self.resources.page_info.has_next
98 }
99
100 /// Returns whether there are results before this page.
101 pub fn has_previous(&self) -> bool {
102 self.resources.page_info.has_previous
103 }
104
105 /// Converts the result to a FHIR SearchBundle.
106 pub fn to_bundle(&self, base_url: &str, self_link: &str) -> SearchBundle {
107 use crate::types::{BundleEntry, SearchBundle};
108
109 let mut bundle = SearchBundle::new().with_self_link(self_link);
110
111 if let Some(total) = self.total {
112 bundle = bundle.with_total(total);
113 }
114
115 // Add next link if there's more data. The self_link already contains
116 // the request's query string (potentially including a `_cursor` param
117 // from the previous page), so we strip any existing `_cursor=` and
118 // append the new one with the correct delimiter.
119 if let Some(ref cursor) = self.resources.page_info.next_cursor {
120 bundle = bundle.with_next_link(replace_cursor_param(self_link, cursor));
121 }
122
123 if let Some(ref cursor) = self.resources.page_info.previous_cursor {
124 bundle = bundle.with_previous_link(replace_cursor_param(self_link, cursor));
125 }
126
127 // First-page link: the self URL with paging params (`_cursor` / `_offset`)
128 // stripped. Emitted only for multi-page results (when a next/previous page
129 // exists). A `last` link is intentionally not emitted: under keyset
130 // (cursor) paging the final page is not cheaply computable.
131 if self.resources.page_info.next_cursor.is_some()
132 || self.resources.page_info.previous_cursor.is_some()
133 {
134 bundle = bundle.with_link("first", strip_paging_params(self_link));
135 }
136
137 // Add matching resources, attaching a relevance score when the backend
138 // computed one for this resource (`Bundle.entry.search.score`).
139 for resource in &self.resources.items {
140 let full_url = format!("{}/{}", base_url, resource.url());
141 let entry = BundleEntry::match_entry(full_url, resource.content().clone())
142 .with_score(self.scores.get(&resource.url()).copied());
143 bundle = bundle.with_entry(entry);
144 }
145
146 // Add included resources
147 for resource in &self.included {
148 let full_url = format!("{}/{}", base_url, resource.url());
149 bundle = bundle.with_entry(BundleEntry::include_entry(
150 full_url,
151 resource.content().clone(),
152 ));
153 }
154
155 bundle
156 }
157}
158
159/// Returns `url` with any existing `_cursor=…` query parameter replaced by the
160/// supplied opaque `cursor` value. Used to build pagination links from the
161/// request's self URL.
162///
163/// Cursors are base64-url-safe so they don't need percent-encoding; the only
164/// surgery required is splitting on the first `?`, dropping any pre-existing
165/// `_cursor` pair, and re-joining with `&`. This is what makes the difference
166/// between
167///
168/// ```text
169/// .../Patient?_count=3&_elements=id?_cursor=… // wrong: literal `?` mid-query
170/// ```
171///
172/// and the spec-compliant
173///
174/// ```text
175/// .../Patient?_count=3&_elements=id&_cursor=…
176/// ```
177/// Returns `url` with any `_cursor=…` and `_offset=…` query parameters removed,
178/// yielding the first-page URL for a paginated search.
179fn strip_paging_params(url: &str) -> String {
180 let (base, query) = match url.find('?') {
181 Some(pos) => (&url[..pos], &url[pos + 1..]),
182 None => return url.to_string(),
183 };
184
185 let parts: Vec<String> = query
186 .split('&')
187 .filter(|p| !p.is_empty() && !p.starts_with("_cursor=") && !p.starts_with("_offset="))
188 .map(str::to_string)
189 .collect();
190
191 if parts.is_empty() {
192 base.to_string()
193 } else {
194 format!("{}?{}", base, parts.join("&"))
195 }
196}
197
198fn replace_cursor_param(url: &str, cursor: &str) -> String {
199 let (base, query) = match url.find('?') {
200 Some(pos) => (&url[..pos], &url[pos + 1..]),
201 None => (url, ""),
202 };
203
204 let mut parts: Vec<String> = query
205 .split('&')
206 .filter(|p| !p.is_empty() && !p.starts_with("_cursor="))
207 .map(str::to_string)
208 .collect();
209 parts.push(format!("_cursor={}", cursor));
210
211 format!("{}?{}", base, parts.join("&"))
212}
213
214/// Basic search provider for single resource type queries.
215///
216/// This trait provides search functionality for a single resource type,
217/// corresponding to the FHIR search interaction:
218/// `GET [base]/[type]?[parameters]`
219///
220/// # Example
221///
222/// ```ignore
223/// use helios_persistence::core::SearchProvider;
224/// use helios_persistence::types::{SearchQuery, SearchParameter, SearchParamType, SearchValue};
225///
226/// async fn search_patients<S: SearchProvider>(
227/// storage: &S,
228/// tenant: &TenantContext,
229/// ) -> Result<(), StorageError> {
230/// let query = SearchQuery::new("Patient")
231/// .with_parameter(SearchParameter {
232/// name: "name".to_string(),
233/// param_type: SearchParamType::String,
234/// modifier: None,
235/// values: vec![SearchValue::eq("Smith")],
236/// chain: vec![],
237/// components: vec![],
238/// })
239/// .with_count(20);
240///
241/// let result = storage.search(tenant, &query).await?;
242///
243/// for resource in result.resources.items {
244/// println!("Found: {}", resource.url());
245/// }
246///
247/// Ok(())
248/// }
249/// ```
250#[async_trait]
251pub trait SearchProvider: ResourceStorage {
252 /// Searches for resources matching the query.
253 ///
254 /// # Arguments
255 ///
256 /// * `tenant` - The tenant context for this operation
257 /// * `query` - The search query with parameters
258 ///
259 /// # Returns
260 ///
261 /// A search result with matching resources and pagination info.
262 ///
263 /// # Errors
264 ///
265 /// * `StorageError::Validation` - If the query contains invalid parameters
266 /// * `StorageError::Search` - If a search feature is not supported
267 /// * `StorageError::Tenant` - If the tenant doesn't have search permission
268 async fn search(
269 &self,
270 tenant: &TenantContext,
271 query: &SearchQuery,
272 ) -> StorageResult<SearchResult>;
273
274 /// Counts resources matching the query without returning them.
275 ///
276 /// This is more efficient than search when you only need the count.
277 async fn search_count(&self, tenant: &TenantContext, query: &SearchQuery)
278 -> StorageResult<u64>;
279
280 /// Returns the backend's search parameter registry.
281 ///
282 /// The registry is the single source of truth for search parameter type
283 /// resolution (see [`crate::search::resolve_param_type`]). REST extractors
284 /// and chained-search builders both consult it so they cannot disagree on
285 /// whether a given param is a Date vs. Token vs. Reference, etc.
286 fn search_param_registry(&self) -> &Arc<RwLock<SearchParameterRegistry>>;
287
288 /// Whether this backend can evaluate `_contained=true|both` searches (which
289 /// require contained-resource indexing). Defaults to `false`; backends that
290 /// index `contained[]` entries override this. The REST layer uses it to
291 /// reject `_contained` with `501` on backends that don't support it rather
292 /// than silently returning an unfiltered result.
293 fn supports_contained_search(&self) -> bool {
294 false
295 }
296
297 /// Returns the search modifiers this backend actually honors for a given
298 /// parameter type (e.g. `exact`, `contains` for strings; `not`, `in` for
299 /// tokens). Used by the REST layer to advertise supported modifiers in the
300 /// CapabilityStatement.
301 ///
302 /// Defaults to an empty list (advertise nothing); real search backends
303 /// override this to reflect what their search implementation accepts so the
304 /// CapabilityStatement stays honest.
305 fn modifiers_for_param_type(&self, param_type: SearchParamType) -> Vec<&'static str> {
306 let _ = param_type;
307 Vec::new()
308 }
309}
310
311/// Search provider that supports searching across multiple resource types.
312///
313/// This extends [`SearchProvider`] to support system-level search:
314/// `GET [base]?[parameters]`
315#[async_trait]
316pub trait MultiTypeSearchProvider: SearchProvider {
317 /// Searches across multiple resource types.
318 ///
319 /// # Arguments
320 ///
321 /// * `tenant` - The tenant context for this operation
322 /// * `resource_types` - The resource types to search (empty = all types)
323 /// * `query` - The search query
324 ///
325 /// # Returns
326 ///
327 /// A search result with matching resources from all specified types.
328 async fn search_multi(
329 &self,
330 tenant: &TenantContext,
331 resource_types: &[&str],
332 query: &SearchQuery,
333 ) -> StorageResult<SearchResult>;
334}
335
336/// Search provider that supports _include.
337///
338/// _include adds referenced resources to the search results.
339#[async_trait]
340pub trait IncludeProvider: SearchProvider {
341 /// Resolves _include directives for search results.
342 ///
343 /// # Arguments
344 ///
345 /// * `tenant` - The tenant context for this operation
346 /// * `resources` - The primary search results
347 /// * `includes` - The include directives to resolve
348 ///
349 /// # Returns
350 ///
351 /// Resources referenced by the primary results according to the include directives.
352 async fn resolve_includes(
353 &self,
354 tenant: &TenantContext,
355 resources: &[StoredResource],
356 includes: &[IncludeDirective],
357 ) -> StorageResult<Vec<StoredResource>>;
358}
359
360/// Search provider that supports _revinclude.
361///
362/// _revinclude adds resources that reference the search results.
363#[async_trait]
364pub trait RevincludeProvider: SearchProvider {
365 /// Resolves _revinclude directives for search results.
366 ///
367 /// # Arguments
368 ///
369 /// * `tenant` - The tenant context for this operation
370 /// * `resources` - The primary search results
371 /// * `revincludes` - The revinclude directives to resolve
372 ///
373 /// # Returns
374 ///
375 /// Resources that reference the primary results according to the revinclude directives.
376 async fn resolve_revincludes(
377 &self,
378 tenant: &TenantContext,
379 resources: &[StoredResource],
380 revincludes: &[IncludeDirective],
381 ) -> StorageResult<Vec<StoredResource>>;
382}
383
384/// Maximum number of `:iterate` hops when transitively following includes,
385/// guarding against reference cycles.
386const MAX_INCLUDE_ITERATE_DEPTH: usize = 5;
387
388/// Upper bound on resources fetched per internal include/revinclude query, to
389/// avoid the default page size silently truncating included resources.
390const INCLUDE_FETCH_LIMIT: u32 = 10_000;
391
392/// Resolves `_include`/`_revinclude` directives for a set of primary matches,
393/// following `:iterate` directives transitively until no new resources are
394/// found (bounded by [`MAX_INCLUDE_ITERATE_DEPTH`]). Included resources are
395/// deduplicated by `type/id` and never include a primary match.
396///
397/// This is the single, backend-agnostic include-resolution path used by the
398/// REST layer for backends whose `search()` does not resolve includes inline
399/// (SQLite, Postgres). References are extracted via the search-parameter
400/// registry's FHIRPath expression — so parameters whose name differs from the
401/// JSON field (e.g. Patient `organization` → `managingOrganization`) resolve
402/// correctly — and the referenced resources are fetched with `search()`.
403pub async fn resolve_includes_iterative<S>(
404 provider: &S,
405 tenant: &TenantContext,
406 matches: &[StoredResource],
407 includes: &[IncludeDirective],
408) -> StorageResult<Vec<StoredResource>>
409where
410 S: SearchProvider + ?Sized,
411{
412 if matches.is_empty() || includes.is_empty() {
413 return Ok(Vec::new());
414 }
415
416 let extractor = SearchParameterExtractor::new(provider.search_param_registry().clone());
417 let key = |r: &StoredResource| format!("{}/{}", r.resource_type(), r.id());
418
419 // Don't re-include primary matches.
420 let mut seen: HashSet<String> = matches.iter().map(&key).collect();
421 let mut included: Vec<StoredResource> = Vec::new();
422
423 let mut frontier: Vec<StoredResource> = matches.to_vec();
424 let mut first_hop = true;
425 let mut depth = 0;
426
427 loop {
428 // First hop applies all directives; later hops only `:iterate` ones.
429 let active: Vec<&IncludeDirective> =
430 includes.iter().filter(|d| first_hop || d.iterate).collect();
431 if active.is_empty() {
432 break;
433 }
434
435 let mut fetched: Vec<StoredResource> = Vec::new();
436 for directive in active {
437 match directive.include_type {
438 IncludeType::Include => {
439 // Forward: collect references from the frontier resources,
440 // then fetch the referenced resources by id.
441 let mut wanted: Vec<(String, String)> = Vec::new();
442 for res in &frontier {
443 if res.resource_type() != directive.source_type {
444 continue;
445 }
446 let def = provider
447 .search_param_registry()
448 .read()
449 .get_param(res.resource_type(), &directive.search_param);
450 let Some(def) = def else { continue };
451 if let Ok(values) = extractor.extract_for_param(res.content(), &def) {
452 for v in values {
453 if let IndexValue::Reference { reference, .. } = v.value {
454 if let Some((t, i)) = reference.split_once('/') {
455 if let Some(target) = &directive.target_type {
456 if t != target {
457 continue;
458 }
459 }
460 wanted.push((t.to_string(), i.to_string()));
461 }
462 }
463 }
464 }
465 }
466 // Group ids by type and fetch each group.
467 let mut by_type: std::collections::HashMap<String, Vec<String>> =
468 std::collections::HashMap::new();
469 for (t, i) in wanted {
470 by_type.entry(t).or_default().push(i);
471 }
472 for (rtype, ids) in by_type {
473 let mut q = SearchQuery::new(&rtype).with_parameter(SearchParameter {
474 name: "_id".to_string(),
475 param_type: SearchParamType::Token,
476 modifier: None,
477 values: ids.iter().map(SearchValue::eq).collect(),
478 chain: vec![],
479 components: vec![],
480 });
481 q.count = Some(INCLUDE_FETCH_LIMIT);
482 let result = provider.search(tenant, &q).await?;
483 fetched.extend(result.resources.items);
484 }
485 }
486 IncludeType::Revinclude => {
487 // Reverse: find source resources that reference any frontier
488 // resource via the directive's reference parameter.
489 let refs: Vec<SearchValue> =
490 frontier.iter().map(|r| SearchValue::eq(key(r))).collect();
491 if refs.is_empty() {
492 continue;
493 }
494 let mut q =
495 SearchQuery::new(&directive.source_type).with_parameter(SearchParameter {
496 name: directive.search_param.clone(),
497 param_type: SearchParamType::Reference,
498 modifier: None,
499 values: refs,
500 chain: vec![],
501 components: vec![],
502 });
503 q.count = Some(INCLUDE_FETCH_LIMIT);
504 let result = provider.search(tenant, &q).await?;
505 fetched.extend(result.resources.items);
506 }
507 }
508 }
509
510 // Dedup against everything seen so far; newly-added become next frontier.
511 let mut next = Vec::new();
512 for r in fetched {
513 if seen.insert(key(&r)) {
514 next.push(r.clone());
515 included.push(r);
516 }
517 }
518
519 first_hop = false;
520 depth += 1;
521 frontier = next;
522 if frontier.is_empty() || depth >= MAX_INCLUDE_ITERATE_DEPTH {
523 break;
524 }
525 }
526
527 Ok(included)
528}
529
530/// Search provider that supports chained parameters and _has.
531///
532/// Chained parameters search on referenced resources:
533/// `Observation?patient.name=Smith`
534///
535/// _has searches for resources referenced by other resources:
536/// `Patient?_has:Observation:patient:code=1234-5`
537#[async_trait]
538pub trait ChainedSearchProvider: SearchProvider {
539 /// Evaluates a chained search and returns matching resource IDs.
540 ///
541 /// This is used internally to resolve chains before the main search.
542 ///
543 /// # Arguments
544 ///
545 /// * `tenant` - The tenant context for this operation
546 /// * `base_type` - The base resource type being searched
547 /// * `chain` - The chain path (e.g., "patient.organization.name")
548 /// * `value` - The value to match
549 ///
550 /// # Returns
551 ///
552 /// IDs of base resources that match the chain condition.
553 async fn resolve_chain(
554 &self,
555 tenant: &TenantContext,
556 base_type: &str,
557 chain: &str,
558 value: &str,
559 ) -> StorageResult<Vec<String>>;
560
561 /// Evaluates a reverse chain (_has) and returns matching resource IDs.
562 ///
563 /// # Arguments
564 ///
565 /// * `tenant` - The tenant context for this operation
566 /// * `base_type` - The base resource type being searched
567 /// * `reverse_chain` - The reverse chain parameters
568 ///
569 /// # Returns
570 ///
571 /// IDs of base resources that are referenced by matching resources.
572 async fn resolve_reverse_chain(
573 &self,
574 tenant: &TenantContext,
575 base_type: &str,
576 reverse_chain: &ReverseChainedParameter,
577 ) -> StorageResult<Vec<String>>;
578}
579
580/// Search provider that supports terminology-aware modifiers.
581///
582/// These modifiers require integration with a terminology service:
583/// - `:above` - Match codes above in the hierarchy
584/// - `:below` - Match codes below in the hierarchy
585/// - `:in` - Match codes in a value set
586/// - `:not-in` - Match codes not in a value set
587#[async_trait]
588pub trait TerminologySearchProvider: SearchProvider {
589 /// Expands a value set and returns member codes.
590 ///
591 /// # Arguments
592 ///
593 /// * `value_set_url` - The canonical URL of the value set
594 ///
595 /// # Returns
596 ///
597 /// A list of (system, code) pairs in the value set.
598 async fn expand_value_set(&self, value_set_url: &str) -> StorageResult<Vec<(String, String)>>;
599
600 /// Gets codes above the given code in the hierarchy.
601 ///
602 /// # Arguments
603 ///
604 /// * `system` - The code system URL
605 /// * `code` - The code to find ancestors for
606 ///
607 /// # Returns
608 ///
609 /// Codes that are ancestors of the given code (including the code itself).
610 async fn codes_above(&self, system: &str, code: &str) -> StorageResult<Vec<String>>;
611
612 /// Gets codes below the given code in the hierarchy.
613 ///
614 /// # Arguments
615 ///
616 /// * `system` - The code system URL
617 /// * `code` - The code to find descendants for
618 ///
619 /// # Returns
620 ///
621 /// Codes that are descendants of the given code (including the code itself).
622 async fn codes_below(&self, system: &str, code: &str) -> StorageResult<Vec<String>>;
623}
624
625/// Search provider that supports full-text search.
626///
627/// Full-text search operations:
628/// - `_text` - Search in the narrative
629/// - `_content` - Search in the entire resource content
630/// - `:text` modifier - Full-text search on token parameters
631#[async_trait]
632pub trait TextSearchProvider: SearchProvider {
633 /// Performs a full-text search on resource narratives.
634 ///
635 /// # Arguments
636 ///
637 /// * `tenant` - The tenant context for this operation
638 /// * `resource_type` - The resource type to search
639 /// * `text` - The text to search for
640 /// * `pagination` - Pagination settings
641 ///
642 /// # Returns
643 ///
644 /// Resources with matching narrative text, ordered by relevance.
645 async fn search_text(
646 &self,
647 tenant: &TenantContext,
648 resource_type: &str,
649 text: &str,
650 pagination: &crate::types::Pagination,
651 ) -> StorageResult<SearchResult>;
652
653 /// Performs a full-text search on entire resource content.
654 ///
655 /// # Arguments
656 ///
657 /// * `tenant` - The tenant context for this operation
658 /// * `resource_type` - The resource type to search
659 /// * `content` - The content to search for
660 /// * `pagination` - Pagination settings
661 ///
662 /// # Returns
663 ///
664 /// Resources with matching content, ordered by relevance.
665 async fn search_content(
666 &self,
667 tenant: &TenantContext,
668 resource_type: &str,
669 content: &str,
670 pagination: &crate::types::Pagination,
671 ) -> StorageResult<SearchResult>;
672}
673
674/// Marker trait for search providers that support all advanced features.
675///
676/// This is a convenience trait that combines all search capabilities.
677pub trait FullSearchProvider:
678 SearchProvider
679 + MultiTypeSearchProvider
680 + IncludeProvider
681 + RevincludeProvider
682 + ChainedSearchProvider
683{
684}
685
686// Blanket implementation for types that implement all required traits
687impl<T> FullSearchProvider for T where
688 T: SearchProvider
689 + MultiTypeSearchProvider
690 + IncludeProvider
691 + RevincludeProvider
692 + ChainedSearchProvider
693{
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::types::PageInfo;
700 use helios_fhir::FhirVersion;
701
702 #[test]
703 fn test_search_result_creation() {
704 let page = Page::new(Vec::new(), PageInfo::end());
705 let result = SearchResult::new(page);
706 assert!(result.included.is_empty());
707 assert!(result.total.is_none());
708 }
709
710 #[test]
711 fn test_search_result_with_included() {
712 let page = Page::new(Vec::new(), PageInfo::end());
713 let result = SearchResult::new(page)
714 .with_included(vec![StoredResource::new(
715 "Patient",
716 "123",
717 crate::tenant::TenantId::new("t1"),
718 serde_json::json!({}),
719 FhirVersion::default(),
720 )])
721 .with_total(100);
722
723 assert_eq!(result.included.len(), 1);
724 assert_eq!(result.total, Some(100));
725 }
726
727 #[test]
728 fn test_search_result_to_bundle() {
729 let resource = StoredResource::new(
730 "Patient",
731 "123",
732 crate::tenant::TenantId::new("t1"),
733 serde_json::json!({"resourceType": "Patient", "id": "123"}),
734 FhirVersion::default(),
735 );
736
737 let page = Page::new(vec![resource], PageInfo::end());
738 let result = SearchResult::new(page).with_total(1);
739
740 let bundle = result.to_bundle("http://example.com/fhir", "http://example.com/fhir/Patient");
741
742 assert_eq!(bundle.total, Some(1));
743 assert_eq!(bundle.entry.len(), 1);
744 // No score recorded -> entry.search.score stays absent.
745 assert!(bundle.entry[0].search.as_ref().unwrap().score.is_none());
746 }
747
748 #[test]
749 fn test_search_result_to_bundle_attaches_score() {
750 let resource = StoredResource::new(
751 "Patient",
752 "123",
753 crate::tenant::TenantId::new("t1"),
754 serde_json::json!({"resourceType": "Patient", "id": "123"}),
755 FhirVersion::default(),
756 );
757 let page = Page::new(vec![resource], PageInfo::end());
758 let mut scores = HashMap::new();
759 scores.insert("Patient/123".to_string(), 0.875);
760 let result = SearchResult::new(page).with_scores(scores);
761
762 let bundle = result.to_bundle("http://example.com/fhir", "http://example.com/fhir/Patient");
763
764 assert_eq!(bundle.entry.len(), 1);
765 assert_eq!(
766 bundle.entry[0].search.as_ref().unwrap().score,
767 Some(0.875),
768 "the matched entry carries Bundle.entry.search.score"
769 );
770 }
771
772 /// Self-link has no query: cursor is appended with `?` (issue #69 bug 1).
773 #[test]
774 fn test_replace_cursor_param_no_query() {
775 let url = replace_cursor_param("http://example.com/fhir/Patient", "abc");
776 assert_eq!(url, "http://example.com/fhir/Patient?_cursor=abc");
777 }
778
779 /// Self-link already has params: cursor is joined with `&`, not `?`. This is
780 /// the core regression — see issue #69. A literal `?` mid-query made
781 /// `urljoin` percent-encode the cursor delimiter, breaking pagination.
782 #[test]
783 fn test_replace_cursor_param_with_existing_params() {
784 let url = replace_cursor_param(
785 "http://example.com/fhir/Patient?_count=3&_elements=id",
786 "abc",
787 );
788 assert_eq!(
789 url,
790 "http://example.com/fhir/Patient?_count=3&_elements=id&_cursor=abc"
791 );
792 }
793
794 /// When the self-link already carries the previous page's cursor (because
795 /// the request URL is reused as the self link), the old cursor is dropped
796 /// before the new one is appended — otherwise pages accumulate stale
797 /// cursors and the next link grows unbounded.
798 #[test]
799 fn test_replace_cursor_param_replaces_existing_cursor() {
800 let url = replace_cursor_param(
801 "http://example.com/fhir/Patient?_count=3&_cursor=old&_elements=id",
802 "new",
803 );
804 assert!(url.starts_with("http://example.com/fhir/Patient?"));
805 assert!(url.contains("_count=3"));
806 assert!(url.contains("_elements=id"));
807 assert!(url.contains("_cursor=new"));
808 assert!(!url.contains("_cursor=old"));
809 assert_eq!(url.matches("_cursor=").count(), 1);
810 }
811
812 /// `to_bundle` should produce a `next` link whose URL contains exactly one
813 /// `_cursor` and uses `&` between query params.
814 #[test]
815 fn test_to_bundle_next_link_format() {
816 let page = Page::new(
817 Vec::<StoredResource>::new(),
818 PageInfo {
819 next_cursor: Some("CURSOR_VALUE".to_string()),
820 previous_cursor: None,
821 total: None,
822 has_next: true,
823 has_previous: false,
824 },
825 );
826 let result = SearchResult::new(page);
827
828 let bundle = result.to_bundle(
829 "http://example.com/fhir",
830 "http://example.com/fhir/Patient?_count=3&_elements=id",
831 );
832
833 let next = bundle
834 .link
835 .iter()
836 .find(|l| l.relation == "next")
837 .expect("next link present");
838 assert_eq!(
839 next.url,
840 "http://example.com/fhir/Patient?_count=3&_elements=id&_cursor=CURSOR_VALUE"
841 );
842 assert_eq!(
843 next.url.matches('?').count(),
844 1,
845 "exactly one '?' delimiter"
846 );
847 }
848}