Skip to main content

helios_persistence/backends/sqlite/
search_impl.rs

1//! Search implementation for SQLite backend.
2//!
3//! This module provides search functionality for the SQLite backend including:
4//! - Basic single-type search
5//! - Multi-type search
6//! - _include and _revinclude support
7//! - Chained search parameter support
8//! - Search parameter filtering using the search_index table
9
10use std::collections::HashSet;
11
12use async_trait::async_trait;
13use chrono::Utc;
14use helios_fhir::FhirVersion;
15use rusqlite::params;
16
17use crate::core::{
18    ChainedSearchProvider, IncludeProvider, MultiTypeSearchProvider, ResourceStorage,
19    RevincludeProvider, SearchProvider, SearchResult,
20};
21use crate::error::{BackendError, StorageError, StorageResult};
22use crate::tenant::TenantContext;
23use crate::types::{
24    CursorDirection, CursorValue, IncludeDirective, Page, PageCursor, PageInfo,
25    ReverseChainedParameter, SearchQuery, SearchValue, StoredResource,
26};
27
28use super::SqliteBackend;
29use super::search::{QueryBuilder, SortValueKind, SqlParam};
30
31fn internal_error(message: String) -> StorageError {
32    StorageError::Backend(BackendError::Internal {
33        backend_name: "sqlite".to_string(),
34        message,
35        source: None,
36    })
37}
38
39/// Binds the cursor's boundary sort value as `?3`, typed per the sort key kind.
40/// Timestamps are stored as RFC3339 text, so they bind (and compare) as text.
41fn bind_cursor_value(
42    params: &mut Vec<Box<dyn rusqlite::ToSql>>,
43    kind: SortValueKind,
44    cursor: &PageCursor,
45) -> StorageResult<()> {
46    let value = cursor.sort_values().first();
47    match kind {
48        SortValueKind::Number => {
49            let n = match value {
50                Some(CursorValue::Decimal(f)) => *f,
51                Some(CursorValue::Number(i)) => *i as f64,
52                Some(CursorValue::String(s)) => s.parse().unwrap_or(0.0),
53                _ => {
54                    return Err(internal_error(
55                        "Invalid cursor: expected number".to_string(),
56                    ));
57                }
58            };
59            params.push(Box::new(n));
60        }
61        SortValueKind::Timestamp | SortValueKind::Text => match value {
62            Some(CursorValue::String(s)) => params.push(Box::new(s.clone())),
63            Some(CursorValue::Null) | None => params.push(Box::new(Option::<String>::None)),
64            _ => {
65                return Err(internal_error("Invalid cursor: expected text".to_string()));
66            }
67        },
68    }
69    Ok(())
70}
71
72#[async_trait]
73impl SearchProvider for SqliteBackend {
74    async fn search(
75        &self,
76        tenant: &TenantContext,
77        query: &SearchQuery,
78    ) -> StorageResult<SearchResult> {
79        // `_contained` search uses a dedicated path (different index columns and
80        // heterogeneous result types); standard search handles `_contained=false`.
81        if query.contained != crate::types::ContainedMode::Off {
82            return self.search_contained(tenant, query).await;
83        }
84
85        // Populate Bundle.total only when the client asked for it
86        // (`_total=accurate|estimate`). Computed up-front, before acquiring the
87        // (non-Send) connection, so it is not held across this await.
88        let total = if query.wants_total() {
89            Some(self.search_count(tenant, query).await?)
90        } else {
91            None
92        };
93
94        let conn = self.get_connection()?;
95        let tenant_id = tenant.tenant_id().as_str();
96        let resource_type = &query.resource_type;
97
98        // Get count with default
99        let count = query.count.unwrap_or(100) as usize;
100
101        // Keyset key for cursor pagination. `None` for multi-field sorts, which
102        // are returned as a single page rather than paged with an inconsistent
103        // keyset.
104        let keyset = QueryBuilder::new(tenant_id, resource_type).primary_keyset_key(query);
105
106        // Only honor an inbound cursor when we can build a keyset comparison.
107        let cursor = if keyset.is_some() {
108            query
109                .cursor
110                .as_ref()
111                .and_then(|c| PageCursor::decode(c).ok())
112        } else {
113            None
114        };
115
116        // Param layout: ?1=tenant, ?2=type, then (cursor) ?3=sort value, ?4=id,
117        // then the search-filter params.
118        let param_offset = if cursor.is_some() { 4 } else { 2 };
119
120        let search_filter = if !query.parameters.is_empty() || query.compartment.is_some() {
121            let builder =
122                QueryBuilder::new(tenant_id, resource_type).with_param_offset(param_offset);
123            let fragment = builder.build(query);
124            if !fragment.sql.is_empty() {
125                Some(fragment)
126            } else {
127                None
128            }
129        } else {
130            None
131        };
132        let filter_clause = search_filter
133            .as_ref()
134            .map(|f| format!(" AND id IN ({})", f.sql))
135            .unwrap_or_default();
136        let search_params = search_filter.map(|f| f.params).unwrap_or_default();
137
138        // SELECT the sort key alongside the row so the next cursor can be built.
139        let select_cols = match &keyset {
140            Some(k) => format!(
141                "id, version_id, data, last_updated, fhir_version, {} AS sort_key",
142                k.expr
143            ),
144            None => "id, version_id, data, last_updated, fhir_version".to_string(),
145        };
146
147        // ORDER BY for the first-page / offset paths.
148        let order_by = if query.sort.is_empty() {
149            "ORDER BY last_updated DESC, id ASC".to_string()
150        } else {
151            QueryBuilder::new(tenant_id, resource_type).build_order_by(query)
152        };
153
154        // Build query based on pagination mode.
155        let (sql, has_previous) = if let (Some(cursor), Some(k)) = (&cursor, &keyset) {
156            let e = &k.expr;
157            let asc = k.direction == crate::types::SortDirection::Ascending;
158            match cursor.direction() {
159                CursorDirection::Next => {
160                    let e_op = if asc { ">" } else { "<" };
161                    let sql = format!(
162                        "SELECT {cols} FROM resources \
163                         WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0{filter} \
164                         AND ({e} {e_op} ?3 OR ({e} = ?3 AND id > ?4)) \
165                         ORDER BY {e} {dir}, id ASC LIMIT {lim}",
166                        cols = select_cols,
167                        filter = filter_clause,
168                        e = e,
169                        e_op = e_op,
170                        dir = if asc { "ASC" } else { "DESC" },
171                        lim = count + 1,
172                    );
173                    (sql, true)
174                }
175                CursorDirection::Previous => {
176                    let e_op = if asc { "<" } else { ">" };
177                    let sql = format!(
178                        "SELECT {cols} FROM resources \
179                         WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0{filter} \
180                         AND ({e} {e_op} ?3 OR ({e} = ?3 AND id < ?4)) \
181                         ORDER BY {e} {dir}, id DESC LIMIT {lim}",
182                        cols = select_cols,
183                        filter = filter_clause,
184                        e = e,
185                        e_op = e_op,
186                        dir = if asc { "DESC" } else { "ASC" },
187                        lim = count + 1,
188                    );
189                    (sql, false)
190                }
191            }
192        } else if let Some(offset) = query.offset {
193            let sql = format!(
194                "SELECT {cols} FROM resources \
195                 WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0{filter} \
196                 {order} LIMIT {lim} OFFSET {off}",
197                cols = select_cols,
198                filter = filter_clause,
199                order = order_by,
200                lim = count + 1,
201                off = offset,
202            );
203            (sql, offset > 0)
204        } else {
205            let sql = format!(
206                "SELECT {cols} FROM resources \
207                 WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0{filter} \
208                 {order} LIMIT {lim}",
209                cols = select_cols,
210                filter = filter_clause,
211                order = order_by,
212                lim = count + 1,
213            );
214            (sql, false)
215        };
216
217        let mut stmt = conn
218            .prepare(&sql)
219            .map_err(|e| internal_error(format!("Failed to prepare search query: {}", e)))?;
220
221        // Bind params: tenant, type, then (cursor) sort value + id, then filter params.
222        let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = vec![
223            Box::new(tenant_id.to_string()),
224            Box::new(resource_type.to_string()),
225        ];
226        if let (Some(cursor), Some(k)) = (&cursor, &keyset) {
227            bind_cursor_value(&mut all_params, k.kind, cursor)?;
228            all_params.push(Box::new(cursor.resource_id().to_string()));
229        }
230        for param in &search_params {
231            match param {
232                SqlParam::String(s) => all_params.push(Box::new(s.clone())),
233                SqlParam::Integer(i) => all_params.push(Box::new(*i)),
234                SqlParam::Float(f) => all_params.push(Box::new(*f)),
235                SqlParam::Null => all_params.push(Box::new(Option::<String>::None)),
236            }
237        }
238        let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
239
240        // The sort key (column 5) is selected only when keyset paging is active.
241        let sort_kind = keyset.as_ref().map(|k| k.kind);
242        let raw_rows: Vec<(String, String, Vec<u8>, String, String, Option<CursorValue>)> = stmt
243            .query_map(param_refs.as_slice(), |row| {
244                let id: String = row.get(0)?;
245                let version_id: String = row.get(1)?;
246                let data: Vec<u8> = row.get(2)?;
247                let last_updated: String = row.get(3)?;
248                let fhir_version: String = row.get(4)?;
249                let sort_key = match sort_kind {
250                    Some(SortValueKind::Number) => {
251                        row.get::<_, Option<f64>>(5)?.map(CursorValue::Decimal)
252                    }
253                    // Timestamps are stored as RFC3339 text, so read text.
254                    Some(_) => row.get::<_, Option<String>>(5)?.map(CursorValue::String),
255                    None => None,
256                };
257                Ok((id, version_id, data, last_updated, fhir_version, sort_key))
258            })
259            .map_err(|e| internal_error(format!("Failed to execute search: {}", e)))?
260            .collect::<Result<Vec<_>, _>>()
261            .map_err(|e| internal_error(format!("Failed to read row: {}", e)))?;
262
263        // Parse rows, carrying the sort key for cursor construction.
264        let mut parsed: Vec<(StoredResource, Option<CursorValue>)> = Vec::new();
265        for (id, version_id, data, last_updated_str, fhir_version_str, sort_key) in raw_rows {
266            let json_data: serde_json::Value = serde_json::from_slice(&data)
267                .map_err(|e| internal_error(format!("Failed to deserialize resource: {}", e)))?;
268
269            let last_updated = chrono::DateTime::parse_from_rfc3339(&last_updated_str)
270                .map_err(|e| internal_error(format!("Failed to parse last_updated: {}", e)))?
271                .with_timezone(&Utc);
272
273            let fhir_version = FhirVersion::from_storage(&fhir_version_str)
274                .unwrap_or_else(helios_fhir::FhirVersion::default_enabled);
275
276            let resource = StoredResource::from_storage(
277                resource_type.clone(),
278                id,
279                version_id,
280                tenant.tenant_id().clone(),
281                json_data,
282                last_updated,
283                last_updated,
284                None,
285                fhir_version,
286            );
287
288            parsed.push((resource, sort_key));
289        }
290
291        // Backward pagination fetched in reverse order — restore sort order.
292        if cursor
293            .as_ref()
294            .map(|c| c.direction() == CursorDirection::Previous)
295            .unwrap_or(false)
296        {
297            parsed.reverse();
298        }
299
300        // We fetched one extra to detect a further page.
301        let has_next = parsed.len() > count;
302        if has_next {
303            parsed.pop();
304        }
305
306        let next_cursor = if has_next {
307            parsed.last().map(|(r, sk)| {
308                PageCursor::new(vec![sk.clone().unwrap_or(CursorValue::Null)], r.id()).encode()
309            })
310        } else {
311            None
312        };
313        let previous_cursor = if has_previous {
314            parsed.first().map(|(r, sk)| {
315                PageCursor::previous(vec![sk.clone().unwrap_or(CursorValue::Null)], r.id()).encode()
316            })
317        } else {
318            None
319        };
320
321        let resources: Vec<StoredResource> = parsed.into_iter().map(|(r, _)| r).collect();
322        let page_info = PageInfo {
323            next_cursor,
324            previous_cursor,
325            total,
326            has_next,
327            has_previous,
328        };
329
330        let page = Page::new(resources, page_info);
331
332        Ok(SearchResult {
333            resources: page,
334            included: Vec::new(),
335            total,
336            scores: Default::default(),
337        })
338    }
339
340    async fn search_count(
341        &self,
342        tenant: &TenantContext,
343        query: &SearchQuery,
344    ) -> StorageResult<u64> {
345        let conn = self.get_connection()?;
346        let tenant_id = tenant.tenant_id().as_str();
347        let resource_type = &query.resource_type;
348
349        // Build the search filter if there are search parameters or a compartment.
350        let (sql, all_params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if !query
351            .parameters
352            .is_empty()
353            || query.compartment.is_some()
354        {
355            let builder = QueryBuilder::new(tenant_id, resource_type).with_param_offset(2);
356            let fragment = builder.build(query);
357
358            let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
359                Box::new(tenant_id.to_string()),
360                Box::new(resource_type.to_string()),
361            ];
362
363            // Add search params
364            for param in &fragment.params {
365                match param {
366                    SqlParam::String(s) => params.push(Box::new(s.clone())),
367                    SqlParam::Integer(i) => params.push(Box::new(*i)),
368                    SqlParam::Float(f) => params.push(Box::new(*f)),
369                    SqlParam::Null => params.push(Box::new(Option::<String>::None)),
370                }
371            }
372
373            let sql = format!(
374                "SELECT COUNT(*) FROM resources WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0 AND id IN ({})",
375                fragment.sql
376            );
377
378            (sql, params)
379        } else {
380            let sql = "SELECT COUNT(*) FROM resources WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0".to_string();
381            let params: Vec<Box<dyn rusqlite::ToSql>> = vec![
382                Box::new(tenant_id.to_string()),
383                Box::new(resource_type.to_string()),
384            ];
385            (sql, params)
386        };
387
388        let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
389
390        let count: i64 = conn
391            .query_row(&sql, param_refs.as_slice(), |row| row.get(0))
392            .map_err(|e| internal_error(format!("Failed to count resources: {}", e)))?;
393
394        Ok(count as u64)
395    }
396
397    fn search_param_registry(
398        &self,
399    ) -> &std::sync::Arc<parking_lot::RwLock<crate::search::SearchParameterRegistry>> {
400        self.search_registry()
401    }
402
403    fn supports_contained_search(&self) -> bool {
404        true
405    }
406
407    fn modifiers_for_param_type(
408        &self,
409        param_type: crate::types::SearchParamType,
410    ) -> Vec<&'static str> {
411        Self::modifiers_for_type(param_type)
412    }
413}
414
415#[async_trait]
416impl MultiTypeSearchProvider for SqliteBackend {
417    async fn search_multi(
418        &self,
419        tenant: &TenantContext,
420        resource_types: &[&str],
421        query: &SearchQuery,
422    ) -> StorageResult<SearchResult> {
423        let conn = self.get_connection()?;
424        let tenant_id = tenant.tenant_id().as_str();
425
426        // Get count and offset with defaults
427        let count = query.count.unwrap_or(100) as usize;
428        let offset = query.offset.unwrap_or(0) as usize;
429
430        // Build the type filter
431        let type_filter = if resource_types.is_empty() {
432            // No filter - search all types
433            String::new()
434        } else {
435            // Filter to specific types
436            let types: Vec<String> = resource_types
437                .iter()
438                .map(|t| format!("'{}'", t.replace('\'', "''")))
439                .collect();
440            format!(" AND resource_type IN ({})", types.join(", "))
441        };
442
443        let sql = format!(
444            "SELECT resource_type, id, version_id, data, last_updated, fhir_version FROM resources
445             WHERE tenant_id = ?1 AND is_deleted = 0{}
446             ORDER BY last_updated DESC
447             LIMIT {} OFFSET {}",
448            type_filter,
449            count + 1,
450            offset
451        );
452
453        let mut stmt = conn
454            .prepare(&sql)
455            .map_err(|e| internal_error(format!("Failed to prepare multi-type search: {}", e)))?;
456
457        let rows = stmt
458            .query_map(params![tenant_id], |row| {
459                let resource_type: String = row.get(0)?;
460                let id: String = row.get(1)?;
461                let version_id: String = row.get(2)?;
462                let data: Vec<u8> = row.get(3)?;
463                let last_updated: String = row.get(4)?;
464                let fhir_version: String = row.get(5)?;
465                Ok((
466                    resource_type,
467                    id,
468                    version_id,
469                    data,
470                    last_updated,
471                    fhir_version,
472                ))
473            })
474            .map_err(|e| internal_error(format!("Failed to execute multi-type search: {}", e)))?;
475
476        let mut resources = Vec::new();
477        for row in rows {
478            let (resource_type, id, version_id, data, last_updated_str, fhir_version_str) =
479                row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?;
480
481            let json_data: serde_json::Value = serde_json::from_slice(&data)
482                .map_err(|e| internal_error(format!("Failed to deserialize resource: {}", e)))?;
483
484            let last_updated = chrono::DateTime::parse_from_rfc3339(&last_updated_str)
485                .map_err(|e| internal_error(format!("Failed to parse last_updated: {}", e)))?
486                .with_timezone(&Utc);
487
488            let fhir_version = FhirVersion::from_storage(&fhir_version_str)
489                .unwrap_or_else(helios_fhir::FhirVersion::default_enabled);
490
491            let resource = StoredResource::from_storage(
492                resource_type,
493                id,
494                version_id,
495                tenant.tenant_id().clone(),
496                json_data,
497                last_updated,
498                last_updated,
499                None,
500                fhir_version,
501            );
502
503            resources.push(resource);
504        }
505
506        // Check if there are more results
507        let has_next = resources.len() > count;
508        if has_next {
509            resources.pop();
510        }
511
512        let page_info = PageInfo {
513            next_cursor: None,
514            previous_cursor: None,
515            total: None,
516            has_next,
517            has_previous: offset > 0,
518        };
519
520        Ok(SearchResult {
521            resources: Page::new(resources, page_info),
522            included: Vec::new(),
523            total: None,
524            scores: Default::default(),
525        })
526    }
527}
528
529#[async_trait]
530impl IncludeProvider for SqliteBackend {
531    async fn resolve_includes(
532        &self,
533        tenant: &TenantContext,
534        resources: &[StoredResource],
535        includes: &[IncludeDirective],
536    ) -> StorageResult<Vec<StoredResource>> {
537        if resources.is_empty() || includes.is_empty() {
538            return Ok(Vec::new());
539        }
540
541        let conn = self.get_connection()?;
542        let tenant_id = tenant.tenant_id().as_str();
543
544        let mut included = Vec::new();
545        let mut seen_refs: HashSet<String> = HashSet::new();
546
547        for include in includes {
548            // For each resource, extract references for the include parameter
549            for resource in resources {
550                // Skip if source type doesn't match
551                if resource.resource_type() != include.source_type {
552                    continue;
553                }
554
555                // Extract references from the resource based on the search parameter
556                let refs = self.extract_references(resource.content(), &include.search_param);
557
558                for reference in refs {
559                    // Parse the reference (e.g., "Patient/123")
560                    if let Some((ref_type, ref_id)) = self.parse_reference(&reference) {
561                        // Apply target type filter if specified
562                        if let Some(ref target) = include.target_type {
563                            if ref_type != *target {
564                                continue;
565                            }
566                        }
567
568                        // Skip if we've already included this resource
569                        let ref_key = format!("{}/{}", ref_type, ref_id);
570                        if seen_refs.contains(&ref_key) {
571                            continue;
572                        }
573                        seen_refs.insert(ref_key);
574
575                        // Fetch the referenced resource
576                        if let Some(included_resource) =
577                            self.fetch_resource(&conn, tenant_id, &ref_type, &ref_id)?
578                        {
579                            included.push(included_resource);
580                        }
581                    }
582                }
583            }
584        }
585
586        Ok(included)
587    }
588}
589
590#[async_trait]
591impl RevincludeProvider for SqliteBackend {
592    async fn resolve_revincludes(
593        &self,
594        tenant: &TenantContext,
595        resources: &[StoredResource],
596        revincludes: &[IncludeDirective],
597    ) -> StorageResult<Vec<StoredResource>> {
598        if resources.is_empty() || revincludes.is_empty() {
599            return Ok(Vec::new());
600        }
601
602        let conn = self.get_connection()?;
603        let tenant_id = tenant.tenant_id().as_str();
604
605        let mut included = Vec::new();
606        let mut seen_ids: HashSet<String> = HashSet::new();
607
608        for revinclude in revincludes {
609            // Build the list of references to search for
610            let mut reference_values: Vec<String> = Vec::new();
611            for resource in resources {
612                // For _revinclude, we look for resources that reference our results
613                // The reference format is typically "ResourceType/id"
614                reference_values.push(format!("{}/{}", resource.resource_type(), resource.id()));
615                // Also check just the ID in case the reference doesn't include the type
616                reference_values.push(resource.id().to_string());
617            }
618
619            if reference_values.is_empty() {
620                continue;
621            }
622
623            // Search for resources of source_type that reference our resources
624            let reference_pattern = reference_values
625                .iter()
626                .map(|r| format!("%{}%", r.replace('%', "\\%").replace('_', "\\_")))
627                .collect::<Vec<_>>();
628
629            // Build SQL to find resources containing any of the references
630            // We search in the JSON data for the search_param field containing a reference
631            let sql = format!(
632                "SELECT id, version_id, data, last_updated, fhir_version FROM resources
633                 WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0
634                 AND ({})",
635                reference_pattern
636                    .iter()
637                    .map(|_| "data LIKE ?".to_string())
638                    .collect::<Vec<_>>()
639                    .join(" OR ")
640            );
641
642            let mut stmt = conn.prepare(&sql).map_err(|e| {
643                internal_error(format!("Failed to prepare revinclude query: {}", e))
644            })?;
645
646            // Build params: tenant_id, source_type, then all the patterns
647            let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
648            param_values.push(Box::new(tenant_id.to_string()));
649            param_values.push(Box::new(revinclude.source_type.clone()));
650            for pattern in &reference_pattern {
651                param_values.push(Box::new(pattern.clone()));
652            }
653
654            let param_refs: Vec<&dyn rusqlite::ToSql> =
655                param_values.iter().map(|p| p.as_ref()).collect();
656
657            let rows = stmt
658                .query_map(param_refs.as_slice(), |row| {
659                    let id: String = row.get(0)?;
660                    let version_id: String = row.get(1)?;
661                    let data: Vec<u8> = row.get(2)?;
662                    let last_updated: String = row.get(3)?;
663                    let fhir_version: String = row.get(4)?;
664                    Ok((id, version_id, data, last_updated, fhir_version))
665                })
666                .map_err(|e| {
667                    internal_error(format!("Failed to execute revinclude query: {}", e))
668                })?;
669
670            for row in rows {
671                let (id, version_id, data, last_updated_str, fhir_version_str) =
672                    row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?;
673
674                // Skip if we've already included this resource
675                let resource_key = format!("{}/{}", revinclude.source_type, id);
676                if seen_ids.contains(&resource_key) {
677                    continue;
678                }
679
680                let json_data: serde_json::Value = serde_json::from_slice(&data)
681                    .map_err(|e| internal_error(format!("Failed to deserialize: {}", e)))?;
682
683                // Verify this resource actually references one of our results via the search_param
684                if !self.verify_reference(&json_data, &revinclude.search_param, &reference_values) {
685                    continue;
686                }
687
688                seen_ids.insert(resource_key);
689
690                let last_updated = chrono::DateTime::parse_from_rfc3339(&last_updated_str)
691                    .map_err(|e| internal_error(format!("Failed to parse last_updated: {}", e)))?
692                    .with_timezone(&Utc);
693
694                let fhir_version = FhirVersion::from_storage(&fhir_version_str)
695                    .unwrap_or_else(helios_fhir::FhirVersion::default_enabled);
696
697                let resource = StoredResource::from_storage(
698                    &revinclude.source_type,
699                    id,
700                    version_id,
701                    tenant.tenant_id().clone(),
702                    json_data,
703                    last_updated,
704                    last_updated,
705                    None,
706                    fhir_version,
707                );
708
709                included.push(resource);
710            }
711        }
712
713        Ok(included)
714    }
715}
716
717#[async_trait]
718impl ChainedSearchProvider for SqliteBackend {
719    async fn resolve_chain(
720        &self,
721        tenant: &TenantContext,
722        base_type: &str,
723        chain: &str,
724        value: &str,
725    ) -> StorageResult<Vec<String>> {
726        use super::search::ChainQueryBuilder;
727
728        let conn = self.get_connection()?;
729        let tenant_id = tenant.tenant_id().as_str();
730
731        if chain.is_empty() {
732            return Ok(Vec::new());
733        }
734
735        // Create the chain query builder with registry access
736        let builder = ChainQueryBuilder::new(tenant_id, base_type, self.get_search_registry())
737            .with_param_offset(2); // After ?1 (tenant) and ?2 (resource_type)
738
739        // Parse the chain
740        let parsed = match builder.parse_chain(chain) {
741            Ok(p) => p,
742            Err(e) => {
743                return Err(internal_error(format!("Failed to parse chain: {}", e)));
744            }
745        };
746
747        // Build the SQL fragment
748        let search_value = SearchValue::eq(value);
749        let fragment = match builder.build_forward_chain_sql(&parsed, &search_value) {
750            Ok(f) => f,
751            Err(e) => {
752                return Err(internal_error(format!("Failed to build chain SQL: {}", e)));
753            }
754        };
755
756        // Execute the query to get matching IDs
757        // The fragment generates: r.id IN (SELECT ...)
758        // We need to wrap it in a proper SELECT FROM resources
759        let sql = format!(
760            "SELECT DISTINCT r.id FROM resources r \
761             WHERE r.tenant_id = ?1 AND r.resource_type = ?2 AND r.is_deleted = 0 AND {}",
762            fragment.sql
763        );
764
765        // Bind parameters: tenant_id, resource_type, then fragment params
766        let mut stmt = conn
767            .prepare(&sql)
768            .map_err(|e| internal_error(format!("Failed to prepare chain query: {}", e)))?;
769
770        // Build parameter vector for rusqlite
771        let mut bound_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
772        bound_params.push(Box::new(tenant_id.to_string()));
773        bound_params.push(Box::new(base_type.to_string()));
774        for param in &fragment.params {
775            match param {
776                SqlParam::String(s) => bound_params.push(Box::new(s.clone())),
777                SqlParam::Integer(i) => bound_params.push(Box::new(*i)),
778                SqlParam::Float(f) => bound_params.push(Box::new(*f)),
779                SqlParam::Null => bound_params.push(Box::new(rusqlite::types::Null)),
780            }
781        }
782
783        let params_ref: Vec<&dyn rusqlite::ToSql> =
784            bound_params.iter().map(|p| p.as_ref()).collect();
785
786        let rows = stmt
787            .query_map(params_ref.as_slice(), |row| row.get::<_, String>(0))
788            .map_err(|e| internal_error(format!("Failed to execute chain query: {}", e)))?;
789
790        let mut ids = Vec::new();
791        for row in rows {
792            ids.push(row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?);
793        }
794
795        Ok(ids)
796    }
797
798    async fn resolve_reverse_chain(
799        &self,
800        tenant: &TenantContext,
801        base_type: &str,
802        reverse_chain: &ReverseChainedParameter,
803    ) -> StorageResult<Vec<String>> {
804        use super::search::ChainQueryBuilder;
805
806        let conn = self.get_connection()?;
807        let tenant_id = tenant.tenant_id().as_str();
808
809        // Create the chain query builder with registry access
810        let builder = ChainQueryBuilder::new(tenant_id, base_type, self.get_search_registry())
811            .with_param_offset(2); // After ?1 (tenant) and ?2 (resource_type)
812
813        // Build the SQL fragment for reverse chain
814        let fragment = match builder.build_reverse_chain_sql(reverse_chain) {
815            Ok(f) => f,
816            Err(e) => {
817                return Err(internal_error(format!(
818                    "Failed to build reverse chain SQL: {}",
819                    e
820                )));
821            }
822        };
823
824        // Execute the query to get matching IDs
825        // The fragment generates: r.id IN (SELECT ...)
826        let sql = format!(
827            "SELECT DISTINCT r.id FROM resources r \
828             WHERE r.tenant_id = ?1 AND r.resource_type = ?2 AND r.is_deleted = 0 AND {}",
829            fragment.sql
830        );
831
832        let mut stmt = conn
833            .prepare(&sql)
834            .map_err(|e| internal_error(format!("Failed to prepare reverse chain query: {}", e)))?;
835
836        // Build parameter vector for rusqlite
837        let mut bound_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
838        bound_params.push(Box::new(tenant_id.to_string()));
839        bound_params.push(Box::new(base_type.to_string()));
840        for param in &fragment.params {
841            match param {
842                SqlParam::String(s) => bound_params.push(Box::new(s.clone())),
843                SqlParam::Integer(i) => bound_params.push(Box::new(*i)),
844                SqlParam::Float(f) => bound_params.push(Box::new(*f)),
845                SqlParam::Null => bound_params.push(Box::new(rusqlite::types::Null)),
846            }
847        }
848
849        let params_ref: Vec<&dyn rusqlite::ToSql> =
850            bound_params.iter().map(|p| p.as_ref()).collect();
851
852        let rows = stmt
853            .query_map(params_ref.as_slice(), |row| row.get::<_, String>(0))
854            .map_err(|e| internal_error(format!("Failed to execute reverse chain query: {}", e)))?;
855
856        let mut ids = Vec::new();
857        for row in rows {
858            ids.push(row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?);
859        }
860
861        Ok(ids)
862    }
863}
864
865/// Finds the `contained[]` entry with the given local `id` in a container's
866/// content.
867fn extract_contained_resource(
868    content: &serde_json::Value,
869    local_id: &str,
870) -> Option<serde_json::Value> {
871    content
872        .get("contained")?
873        .as_array()?
874        .iter()
875        .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(local_id))
876        .cloned()
877}
878
879/// Builds a `StoredResource` for a contained resource, inheriting the
880/// container's version/tenant/timestamps. Used for `_containedType=contained`.
881fn build_contained_stored(
882    container: &StoredResource,
883    contained_type: &str,
884    local_id: &str,
885    content: serde_json::Value,
886) -> StoredResource {
887    StoredResource::from_storage(
888        contained_type.to_string(),
889        local_id.to_string(),
890        container.version_id().to_string(),
891        container.tenant_id().clone(),
892        content,
893        container.created_at(),
894        container.last_modified(),
895        None,
896        container.fhir_version(),
897    )
898}
899
900// Contained (`_contained`) search.
901impl SqliteBackend {
902    /// Executes a `_contained=true|both` search.
903    ///
904    /// Matches contained resources of `query.resource_type` via the
905    /// `is_contained` index rows, then returns either the container resources
906    /// (`_containedType=container`, default) or the contained resources
907    /// themselves (`_containedType=contained`). For `_contained=both`, top-level
908    /// matches (run through the standard path) are merged in first.
909    ///
910    /// Paginated by `_offset`/`_count` as a single window (no keyset cursor);
911    /// contained result sets are expected to be small.
912    async fn search_contained(
913        &self,
914        tenant: &TenantContext,
915        query: &SearchQuery,
916    ) -> StorageResult<SearchResult> {
917        use crate::types::{ContainedMode, ContainedReturn};
918
919        let tenant_id = tenant.tenant_id().as_str();
920        let contained_type = query.resource_type.as_str();
921
922        // 1. Resolve contained matches → (container_type, container_id, local_id).
923        let builder = QueryBuilder::new(tenant_id, contained_type);
924        let matches: Vec<(String, String, Option<String>)> = match builder.build_contained(query) {
925            Some(fragment) => {
926                let conn = self.get_connection()?;
927                let mut stmt = conn.prepare(&fragment.sql).map_err(|e| {
928                    internal_error(format!("Failed to prepare contained query: {e}"))
929                })?;
930                let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = vec![
931                    Box::new(tenant_id.to_string()),
932                    Box::new(contained_type.to_string()),
933                ];
934                for param in &fragment.params {
935                    match param {
936                        SqlParam::String(s) => all_params.push(Box::new(s.clone())),
937                        SqlParam::Integer(i) => all_params.push(Box::new(*i)),
938                        SqlParam::Float(f) => all_params.push(Box::new(*f)),
939                        SqlParam::Null => all_params.push(Box::new(Option::<String>::None)),
940                    }
941                }
942                let refs: Vec<&dyn rusqlite::ToSql> =
943                    all_params.iter().map(|p| p.as_ref()).collect();
944                stmt.query_map(refs.as_slice(), |row| {
945                    Ok((
946                        row.get::<_, String>(0)?,
947                        row.get::<_, String>(1)?,
948                        row.get::<_, Option<String>>(2)?,
949                    ))
950                })
951                .map_err(|e| internal_error(format!("Failed to execute contained query: {e}")))?
952                .collect::<Result<Vec<_>, _>>()
953                .map_err(|e| internal_error(format!("Failed to read contained row: {e}")))?
954            }
955            None => Vec::new(),
956        };
957
958        // 2. Materialize result items (container or contained), de-duplicated.
959        let mut items: Vec<StoredResource> = Vec::new();
960        let mut seen: HashSet<String> = HashSet::new();
961        match query.contained_return {
962            ContainedReturn::Container => {
963                for (ctype, cid, _) in &matches {
964                    if !seen.insert(format!("{ctype}/{cid}")) {
965                        continue;
966                    }
967                    if let Some(container) = self.read(tenant, ctype, cid).await? {
968                        items.push(container);
969                    }
970                }
971            }
972            ContainedReturn::Contained => {
973                for (ctype, cid, local) in &matches {
974                    let Some(local_id) = local else { continue };
975                    if !seen.insert(format!("{ctype}/{cid}#{local_id}")) {
976                        continue;
977                    }
978                    if let Some(container) = self.read(tenant, ctype, cid).await? {
979                        if let Some(c) = extract_contained_resource(container.content(), local_id) {
980                            items.push(build_contained_stored(
981                                &container,
982                                contained_type,
983                                local_id,
984                                c,
985                            ));
986                        }
987                    }
988                }
989            }
990        }
991
992        // 3. For `both`, merge top-level matches ahead of contained ones.
993        if query.contained == ContainedMode::Both {
994            let mut top_query = query.clone();
995            top_query.contained = ContainedMode::Off;
996            top_query.contained_return = ContainedReturn::Container;
997            let top = self.search(tenant, &top_query).await?;
998            let mut merged = top.resources.items;
999            let top_urls: HashSet<String> = merged.iter().map(|r| r.url()).collect();
1000            for item in items {
1001                if !top_urls.contains(&item.url()) {
1002                    merged.push(item);
1003                }
1004            }
1005            items = merged;
1006        }
1007
1008        // 4. Apply the offset/count window.
1009        let count = query.count.unwrap_or(100) as usize;
1010        let offset = query.offset.unwrap_or(0) as usize;
1011        let total_matches = items.len() as u64;
1012        let windowed: Vec<StoredResource> = items.into_iter().skip(offset).take(count).collect();
1013
1014        let total = if query.wants_total() {
1015            Some(total_matches)
1016        } else {
1017            None
1018        };
1019        let page = Page::new(windowed, PageInfo::end());
1020        let mut result = SearchResult::new(page);
1021        if let Some(t) = total {
1022            result = result.with_total(t);
1023        }
1024        Ok(result)
1025    }
1026}
1027
1028// Helper methods for search implementations
1029impl SqliteBackend {
1030    /// Extract references from a resource for a given search parameter.
1031    fn extract_references(&self, content: &serde_json::Value, search_param: &str) -> Vec<String> {
1032        let mut refs = Vec::new();
1033
1034        // Try direct field access (e.g., "subject" -> content.subject)
1035        if let Some(value) = content.get(search_param) {
1036            self.collect_references_from_value(value, &mut refs);
1037        }
1038
1039        // Try common reference field patterns
1040        // Many FHIR references are in fields like "patient", "subject", "performer", etc.
1041        // and contain a "reference" sub-field
1042        refs
1043    }
1044
1045    /// Recursively collect reference strings from a JSON value.
1046    #[allow(clippy::only_used_in_recursion)]
1047    fn collect_references_from_value(&self, value: &serde_json::Value, refs: &mut Vec<String>) {
1048        match value {
1049            serde_json::Value::Object(obj) => {
1050                // Check for "reference" field
1051                if let Some(serde_json::Value::String(ref_str)) = obj.get("reference") {
1052                    refs.push(ref_str.clone());
1053                }
1054                // Recurse into object fields
1055                for v in obj.values() {
1056                    self.collect_references_from_value(v, refs);
1057                }
1058            }
1059            serde_json::Value::Array(arr) => {
1060                for item in arr {
1061                    self.collect_references_from_value(item, refs);
1062                }
1063            }
1064            _ => {}
1065        }
1066    }
1067
1068    /// Parse a reference string into (type, id).
1069    fn parse_reference(&self, reference: &str) -> Option<(String, String)> {
1070        // Handle formats:
1071        // - "Patient/123"
1072        // - "http://example.com/fhir/Patient/123"
1073        let path = reference
1074            .strip_prefix("http://")
1075            .or_else(|| reference.strip_prefix("https://"))
1076            .map(|s| s.rsplit('/').take(2).collect::<Vec<_>>())
1077            .unwrap_or_else(|| reference.split('/').collect());
1078
1079        if path.len() >= 2 {
1080            // For URL format, path is reversed
1081            if reference.starts_with("http") {
1082                Some((path[1].to_string(), path[0].to_string()))
1083            } else {
1084                Some((path[0].to_string(), path[1].to_string()))
1085            }
1086        } else {
1087            None
1088        }
1089    }
1090
1091    /// Fetch a single resource by type and ID.
1092    fn fetch_resource(
1093        &self,
1094        conn: &rusqlite::Connection,
1095        tenant_id: &str,
1096        resource_type: &str,
1097        id: &str,
1098    ) -> StorageResult<Option<StoredResource>> {
1099        let result = conn.query_row(
1100            "SELECT version_id, data, last_updated, fhir_version FROM resources
1101             WHERE tenant_id = ?1 AND resource_type = ?2 AND id = ?3 AND is_deleted = 0",
1102            params![tenant_id, resource_type, id],
1103            |row| {
1104                let version_id: String = row.get(0)?;
1105                let data: Vec<u8> = row.get(1)?;
1106                let last_updated: String = row.get(2)?;
1107                let fhir_version: String = row.get(3)?;
1108                Ok((version_id, data, last_updated, fhir_version))
1109            },
1110        );
1111
1112        match result {
1113            Ok((version_id, data, last_updated_str, fhir_version_str)) => {
1114                let json_data: serde_json::Value = serde_json::from_slice(&data)
1115                    .map_err(|e| internal_error(format!("Failed to deserialize: {}", e)))?;
1116
1117                let last_updated = chrono::DateTime::parse_from_rfc3339(&last_updated_str)
1118                    .map_err(|e| internal_error(format!("Failed to parse last_updated: {}", e)))?
1119                    .with_timezone(&Utc);
1120
1121                let fhir_version = FhirVersion::from_storage(&fhir_version_str)
1122                    .unwrap_or_else(helios_fhir::FhirVersion::default_enabled);
1123
1124                Ok(Some(StoredResource::from_storage(
1125                    resource_type,
1126                    id,
1127                    version_id,
1128                    crate::tenant::TenantId::new(tenant_id),
1129                    json_data,
1130                    last_updated,
1131                    last_updated,
1132                    None,
1133                    fhir_version,
1134                )))
1135            }
1136            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1137            Err(e) => Err(internal_error(format!("Failed to fetch resource: {}", e))),
1138        }
1139    }
1140
1141    /// Verify that a resource contains a reference to one of the given values.
1142    fn verify_reference(
1143        &self,
1144        content: &serde_json::Value,
1145        search_param: &str,
1146        reference_values: &[String],
1147    ) -> bool {
1148        let refs = self.extract_references(content, search_param);
1149        for ref_str in refs {
1150            // Check full reference
1151            if reference_values.iter().any(|v| ref_str.contains(v)) {
1152                return true;
1153            }
1154            // Check just the ID part
1155            if let Some((_, ref_id)) = self.parse_reference(&ref_str) {
1156                if reference_values.contains(&ref_id) {
1157                    return true;
1158                }
1159            }
1160        }
1161        false
1162    }
1163
1164    /// Find resources matching a simple field value search using the search index.
1165    #[allow(dead_code)]
1166    fn find_resources_by_value(
1167        &self,
1168        conn: &rusqlite::Connection,
1169        tenant_id: &str,
1170        resource_type: &str,
1171        param_name: &str,
1172        value: &str,
1173    ) -> StorageResult<Vec<String>> {
1174        // Use the pre-computed search_index table instead
1175        // This is consistent with our PrecomputedIndex strategy
1176
1177        // Handle token format (system|code or just code)
1178        let (system_clause, search_value) = if value.contains('|') {
1179            let parts: Vec<&str> = value.splitn(2, '|').collect();
1180            if parts.len() == 2 && !parts[0].is_empty() {
1181                // system|code format
1182                (
1183                    format!(
1184                        "AND value_token_system = '{}'",
1185                        parts[0].replace('\'', "''")
1186                    ),
1187                    parts[1].to_string(),
1188                )
1189            } else if parts.len() == 2 {
1190                // |code format (no system)
1191                (
1192                    "AND (value_token_system IS NULL OR value_token_system = '')".to_string(),
1193                    parts[1].to_string(),
1194                )
1195            } else {
1196                (String::new(), value.to_string())
1197            }
1198        } else {
1199            (String::new(), value.to_string())
1200        };
1201
1202        let escaped_value = search_value.replace('\'', "''");
1203
1204        // Query the search_index table for matching resources
1205        // Search across string, token code, and reference values
1206        let sql = format!(
1207            "SELECT DISTINCT resource_id FROM search_index
1208             WHERE tenant_id = ?1 AND resource_type = ?2 AND param_name = ?3
1209             AND (
1210                 value_string LIKE '%{}%' COLLATE NOCASE
1211                 OR value_token_code = '{}'
1212                 OR value_token_code LIKE '%{}%'
1213                 OR value_reference LIKE '%{}%'
1214             )
1215             {}",
1216            escaped_value, escaped_value, escaped_value, escaped_value, system_clause
1217        );
1218
1219        let mut stmt = conn
1220            .prepare(&sql)
1221            .map_err(|e| internal_error(format!("Failed to prepare find query: {}", e)))?;
1222
1223        let rows = stmt
1224            .query_map(params![tenant_id, resource_type, param_name], |row| {
1225                row.get::<_, String>(0)
1226            })
1227            .map_err(|e| internal_error(format!("Failed to execute find query: {}", e)))?;
1228
1229        let mut ids = Vec::new();
1230        for row in rows {
1231            ids.push(row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?);
1232        }
1233
1234        Ok(ids)
1235    }
1236
1237    /// Get all resources of a type for a tenant.
1238    #[allow(dead_code)]
1239    fn get_all_resources(
1240        &self,
1241        conn: &rusqlite::Connection,
1242        tenant_id: &str,
1243        resource_type: &str,
1244    ) -> StorageResult<Vec<StoredResource>> {
1245        let mut stmt = conn
1246            .prepare(
1247                "SELECT id, version_id, data, last_updated, fhir_version FROM resources
1248                 WHERE tenant_id = ?1 AND resource_type = ?2 AND is_deleted = 0",
1249            )
1250            .map_err(|e| internal_error(format!("Failed to prepare query: {}", e)))?;
1251
1252        let rows = stmt
1253            .query_map(params![tenant_id, resource_type], |row| {
1254                let id: String = row.get(0)?;
1255                let version_id: String = row.get(1)?;
1256                let data: Vec<u8> = row.get(2)?;
1257                let last_updated: String = row.get(3)?;
1258                let fhir_version: String = row.get(4)?;
1259                Ok((id, version_id, data, last_updated, fhir_version))
1260            })
1261            .map_err(|e| internal_error(format!("Failed to query resources: {}", e)))?;
1262
1263        let mut resources = Vec::new();
1264        for row in rows {
1265            let (id, version_id, data, last_updated_str, fhir_version_str) =
1266                row.map_err(|e| internal_error(format!("Failed to read row: {}", e)))?;
1267
1268            let json_data: serde_json::Value = serde_json::from_slice(&data)
1269                .map_err(|e| internal_error(format!("Failed to deserialize: {}", e)))?;
1270
1271            let last_updated = chrono::DateTime::parse_from_rfc3339(&last_updated_str)
1272                .map_err(|e| internal_error(format!("Failed to parse last_updated: {}", e)))?
1273                .with_timezone(&Utc);
1274
1275            let fhir_version = FhirVersion::from_storage(&fhir_version_str)
1276                .unwrap_or_else(helios_fhir::FhirVersion::default_enabled);
1277
1278            resources.push(StoredResource::from_storage(
1279                resource_type,
1280                id,
1281                version_id,
1282                crate::tenant::TenantId::new(tenant_id),
1283                json_data,
1284                last_updated,
1285                last_updated,
1286                None,
1287                fhir_version,
1288            ));
1289        }
1290
1291        Ok(resources)
1292    }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::*;
1298    use crate::core::ResourceStorage;
1299    use crate::tenant::{TenantId, TenantPermissions};
1300    use crate::types::SearchParameter;
1301    use serde_json::json;
1302
1303    fn create_test_backend() -> SqliteBackend {
1304        // Point at the workspace's data directory so the search-parameter
1305        // registry loads the full FHIR spec (otherwise only the 5 minimal
1306        // embedded params are available and chained-search tests fail to
1307        // resolve param types like Observation.code → Token).
1308        let data_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1309            .join("..")
1310            .join("..")
1311            .join("data");
1312        let mut config = crate::backends::sqlite::backend::SqliteBackendConfig::default();
1313        config.data_dir = Some(data_dir);
1314        let backend = SqliteBackend::with_config(":memory:", config).unwrap();
1315        backend.init_schema().unwrap();
1316        backend
1317    }
1318
1319    fn create_test_tenant() -> TenantContext {
1320        TenantContext::new(
1321            TenantId::new("test-tenant"),
1322            TenantPermissions::full_access(),
1323        )
1324    }
1325
1326    #[tokio::test]
1327    async fn test_search_empty() {
1328        let backend = create_test_backend();
1329        let tenant = create_test_tenant();
1330
1331        let query = SearchQuery::new("Patient");
1332        let result = backend.search(&tenant, &query).await.unwrap();
1333
1334        assert!(result.resources.items.is_empty());
1335    }
1336
1337    #[tokio::test]
1338    async fn test_search_returns_resources() {
1339        let backend = create_test_backend();
1340        let tenant = create_test_tenant();
1341
1342        // Create some resources
1343        backend
1344            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1345            .await
1346            .unwrap();
1347        backend
1348            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1349            .await
1350            .unwrap();
1351
1352        let query = SearchQuery::new("Patient");
1353        let result = backend.search(&tenant, &query).await.unwrap();
1354
1355        assert_eq!(result.resources.items.len(), 2);
1356    }
1357
1358    #[tokio::test]
1359    async fn test_search_count() {
1360        let backend = create_test_backend();
1361        let tenant = create_test_tenant();
1362
1363        backend
1364            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1365            .await
1366            .unwrap();
1367        backend
1368            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1369            .await
1370            .unwrap();
1371        backend
1372            .create(&tenant, "Observation", json!({}), FhirVersion::default())
1373            .await
1374            .unwrap();
1375
1376        let query = SearchQuery::new("Patient");
1377        let count = backend.search_count(&tenant, &query).await.unwrap();
1378
1379        assert_eq!(count, 2);
1380    }
1381
1382    #[tokio::test]
1383    async fn test_search_tenant_isolation() {
1384        let backend = create_test_backend();
1385
1386        let tenant1 =
1387            TenantContext::new(TenantId::new("tenant-1"), TenantPermissions::full_access());
1388        let tenant2 =
1389            TenantContext::new(TenantId::new("tenant-2"), TenantPermissions::full_access());
1390
1391        backend
1392            .create(&tenant1, "Patient", json!({}), FhirVersion::default())
1393            .await
1394            .unwrap();
1395        backend
1396            .create(&tenant2, "Patient", json!({}), FhirVersion::default())
1397            .await
1398            .unwrap();
1399        backend
1400            .create(&tenant2, "Patient", json!({}), FhirVersion::default())
1401            .await
1402            .unwrap();
1403
1404        let query = SearchQuery::new("Patient");
1405
1406        let result1 = backend.search(&tenant1, &query).await.unwrap();
1407        assert_eq!(result1.resources.items.len(), 1);
1408
1409        let result2 = backend.search(&tenant2, &query).await.unwrap();
1410        assert_eq!(result2.resources.items.len(), 2);
1411    }
1412
1413    // ========================================================================
1414    // Cursor Pagination Tests
1415    // ========================================================================
1416
1417    #[tokio::test]
1418    async fn test_cursor_pagination_basic() {
1419        let backend = create_test_backend();
1420        let tenant = create_test_tenant();
1421
1422        // Create 5 resources
1423        for i in 0..5 {
1424            backend
1425                .create(
1426                    &tenant,
1427                    "Patient",
1428                    json!({"name": format!("Patient{}", i)}),
1429                    FhirVersion::default(),
1430                )
1431                .await
1432                .unwrap();
1433        }
1434
1435        // First page with limit of 2
1436        let query = SearchQuery::new("Patient").with_count(2);
1437        let page1 = backend.search(&tenant, &query).await.unwrap();
1438
1439        assert_eq!(page1.resources.items.len(), 2);
1440        assert!(page1.resources.page_info.has_next);
1441        assert!(page1.resources.page_info.next_cursor.is_some());
1442
1443        // Second page using cursor
1444        let cursor = page1.resources.page_info.next_cursor.unwrap();
1445        let query2 = SearchQuery::new("Patient")
1446            .with_count(2)
1447            .with_cursor(cursor);
1448        let page2 = backend.search(&tenant, &query2).await.unwrap();
1449
1450        assert_eq!(page2.resources.items.len(), 2);
1451        assert!(page2.resources.page_info.has_next);
1452        assert!(page2.resources.page_info.has_previous);
1453
1454        // Third page (last)
1455        let cursor = page2.resources.page_info.next_cursor.unwrap();
1456        let query3 = SearchQuery::new("Patient")
1457            .with_count(2)
1458            .with_cursor(cursor);
1459        let page3 = backend.search(&tenant, &query3).await.unwrap();
1460
1461        assert_eq!(page3.resources.items.len(), 1);
1462        assert!(!page3.resources.page_info.has_next);
1463        assert!(page3.resources.page_info.next_cursor.is_none());
1464
1465        // Verify no overlapping IDs
1466        let page1_ids: Vec<_> = page1.resources.items.iter().map(|r| r.id()).collect();
1467        let page2_ids: Vec<_> = page2.resources.items.iter().map(|r| r.id()).collect();
1468        let page3_ids: Vec<_> = page3.resources.items.iter().map(|r| r.id()).collect();
1469
1470        for id in &page1_ids {
1471            assert!(!page2_ids.contains(id), "Page 1 and 2 should not overlap");
1472            assert!(!page3_ids.contains(id), "Page 1 and 3 should not overlap");
1473        }
1474        for id in &page2_ids {
1475            assert!(!page3_ids.contains(id), "Page 2 and 3 should not overlap");
1476        }
1477    }
1478
1479    #[tokio::test]
1480    async fn test_cursor_pagination_no_more_results() {
1481        let backend = create_test_backend();
1482        let tenant = create_test_tenant();
1483
1484        // Create 3 resources
1485        for _ in 0..3 {
1486            backend
1487                .create(&tenant, "Patient", json!({}), FhirVersion::default())
1488                .await
1489                .unwrap();
1490        }
1491
1492        // Request more than available
1493        let query = SearchQuery::new("Patient").with_count(10);
1494        let result = backend.search(&tenant, &query).await.unwrap();
1495
1496        assert_eq!(result.resources.items.len(), 3);
1497        assert!(!result.resources.page_info.has_next);
1498        assert!(result.resources.page_info.next_cursor.is_none());
1499    }
1500
1501    #[tokio::test]
1502    async fn test_cursor_pagination_empty() {
1503        let backend = create_test_backend();
1504        let tenant = create_test_tenant();
1505
1506        let query = SearchQuery::new("Patient").with_count(10);
1507        let result = backend.search(&tenant, &query).await.unwrap();
1508
1509        assert!(result.resources.items.is_empty());
1510        assert!(!result.resources.page_info.has_next);
1511        assert!(!result.resources.page_info.has_previous);
1512    }
1513
1514    // ========================================================================
1515    // MultiTypeSearchProvider Tests
1516    // ========================================================================
1517
1518    #[tokio::test]
1519    async fn test_search_multi_all_types() {
1520        let backend = create_test_backend();
1521        let tenant = create_test_tenant();
1522
1523        // Create different resource types
1524        backend
1525            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1526            .await
1527            .unwrap();
1528        backend
1529            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1530            .await
1531            .unwrap();
1532        backend
1533            .create(&tenant, "Observation", json!({}), FhirVersion::default())
1534            .await
1535            .unwrap();
1536        backend
1537            .create(&tenant, "Encounter", json!({}), FhirVersion::default())
1538            .await
1539            .unwrap();
1540
1541        // Search all types (empty list)
1542        let query = SearchQuery::new("Patient"); // Type in query doesn't matter for multi
1543        let result = backend.search_multi(&tenant, &[], &query).await.unwrap();
1544
1545        // Should find all 4 resources
1546        assert_eq!(result.resources.items.len(), 4);
1547    }
1548
1549    #[tokio::test]
1550    async fn test_search_multi_specific_types() {
1551        let backend = create_test_backend();
1552        let tenant = create_test_tenant();
1553
1554        // Create different resource types
1555        backend
1556            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1557            .await
1558            .unwrap();
1559        backend
1560            .create(&tenant, "Patient", json!({}), FhirVersion::default())
1561            .await
1562            .unwrap();
1563        backend
1564            .create(&tenant, "Observation", json!({}), FhirVersion::default())
1565            .await
1566            .unwrap();
1567        backend
1568            .create(&tenant, "Encounter", json!({}), FhirVersion::default())
1569            .await
1570            .unwrap();
1571
1572        // Search only Patient and Observation
1573        let query = SearchQuery::new("Patient");
1574        let result = backend
1575            .search_multi(&tenant, &["Patient", "Observation"], &query)
1576            .await
1577            .unwrap();
1578
1579        // Should find 3 resources
1580        assert_eq!(result.resources.items.len(), 3);
1581
1582        // Verify types
1583        let types: Vec<&str> = result
1584            .resources
1585            .items
1586            .iter()
1587            .map(|r| r.resource_type())
1588            .collect();
1589        assert!(types.contains(&"Patient"));
1590        assert!(types.contains(&"Observation"));
1591        assert!(!types.contains(&"Encounter"));
1592    }
1593
1594    #[tokio::test]
1595    async fn test_search_multi_tenant_isolation() {
1596        let backend = create_test_backend();
1597        let tenant1 =
1598            TenantContext::new(TenantId::new("tenant-1"), TenantPermissions::full_access());
1599        let tenant2 =
1600            TenantContext::new(TenantId::new("tenant-2"), TenantPermissions::full_access());
1601
1602        backend
1603            .create(&tenant1, "Patient", json!({}), FhirVersion::default())
1604            .await
1605            .unwrap();
1606        backend
1607            .create(&tenant2, "Patient", json!({}), FhirVersion::default())
1608            .await
1609            .unwrap();
1610        backend
1611            .create(&tenant2, "Observation", json!({}), FhirVersion::default())
1612            .await
1613            .unwrap();
1614
1615        let query = SearchQuery::new("Patient");
1616
1617        let result1 = backend.search_multi(&tenant1, &[], &query).await.unwrap();
1618        assert_eq!(result1.resources.items.len(), 1);
1619
1620        let result2 = backend.search_multi(&tenant2, &[], &query).await.unwrap();
1621        assert_eq!(result2.resources.items.len(), 2);
1622    }
1623
1624    // ========================================================================
1625    // IncludeProvider Tests
1626    // ========================================================================
1627
1628    #[tokio::test]
1629    async fn test_resolve_includes_basic() {
1630        let backend = create_test_backend();
1631        let tenant = create_test_tenant();
1632
1633        // Create a patient
1634        let _patient = backend
1635            .create(
1636                &tenant,
1637                "Patient",
1638                json!({"id": "p1", "name": [{"family": "Smith"}]}),
1639                FhirVersion::default(),
1640            )
1641            .await
1642            .unwrap();
1643
1644        // Create an observation that references the patient
1645        let observation = backend
1646            .create(
1647                &tenant,
1648                "Observation",
1649                json!({
1650                    "id": "o1",
1651                    "subject": {"reference": "Patient/p1"},
1652                    "code": {"text": "Blood pressure"}
1653                }),
1654                FhirVersion::default(),
1655            )
1656            .await
1657            .unwrap();
1658
1659        // Resolve includes for the observation
1660        let include = IncludeDirective {
1661            include_type: crate::types::IncludeType::Include,
1662            source_type: "Observation".to_string(),
1663            search_param: "subject".to_string(),
1664            target_type: None,
1665            iterate: false,
1666        };
1667
1668        let included = backend
1669            .resolve_includes(&tenant, &[observation], &[include])
1670            .await
1671            .unwrap();
1672
1673        // Should include the patient
1674        assert_eq!(included.len(), 1);
1675        assert_eq!(included[0].resource_type(), "Patient");
1676        assert_eq!(included[0].id(), "p1");
1677    }
1678
1679    #[tokio::test]
1680    async fn test_resolve_includes_with_target_type_filter() {
1681        let backend = create_test_backend();
1682        let tenant = create_test_tenant();
1683
1684        // Create resources
1685        backend
1686            .create(
1687                &tenant,
1688                "Patient",
1689                json!({"id": "p1"}),
1690                FhirVersion::default(),
1691            )
1692            .await
1693            .unwrap();
1694        backend
1695            .create(
1696                &tenant,
1697                "Practitioner",
1698                json!({"id": "pr1"}),
1699                FhirVersion::default(),
1700            )
1701            .await
1702            .unwrap();
1703
1704        let observation = backend
1705            .create(
1706                &tenant,
1707                "Observation",
1708                json!({
1709                    "id": "o1",
1710                    "subject": {"reference": "Patient/p1"},
1711                    "performer": [{"reference": "Practitioner/pr1"}]
1712                }),
1713                FhirVersion::default(),
1714            )
1715            .await
1716            .unwrap();
1717
1718        // Include only Patient references
1719        let include = IncludeDirective {
1720            include_type: crate::types::IncludeType::Include,
1721            source_type: "Observation".to_string(),
1722            search_param: "subject".to_string(),
1723            target_type: Some("Patient".to_string()),
1724            iterate: false,
1725        };
1726
1727        let included = backend
1728            .resolve_includes(&tenant, &[observation], &[include])
1729            .await
1730            .unwrap();
1731
1732        assert_eq!(included.len(), 1);
1733        assert_eq!(included[0].resource_type(), "Patient");
1734    }
1735
1736    #[tokio::test]
1737    async fn test_resolve_includes_empty_resources() {
1738        let backend = create_test_backend();
1739        let tenant = create_test_tenant();
1740
1741        let include = IncludeDirective {
1742            include_type: crate::types::IncludeType::Include,
1743            source_type: "Observation".to_string(),
1744            search_param: "subject".to_string(),
1745            target_type: None,
1746            iterate: false,
1747        };
1748
1749        let included = backend
1750            .resolve_includes(&tenant, &[], &[include])
1751            .await
1752            .unwrap();
1753
1754        assert!(included.is_empty());
1755    }
1756
1757    #[tokio::test]
1758    async fn test_resolve_includes_tenant_isolation() {
1759        let backend = create_test_backend();
1760        let tenant1 =
1761            TenantContext::new(TenantId::new("tenant-1"), TenantPermissions::full_access());
1762        let tenant2 =
1763            TenantContext::new(TenantId::new("tenant-2"), TenantPermissions::full_access());
1764
1765        // Create patient in tenant 1
1766        backend
1767            .create(
1768                &tenant1,
1769                "Patient",
1770                json!({"id": "p1"}),
1771                FhirVersion::default(),
1772            )
1773            .await
1774            .unwrap();
1775
1776        // Create observation in tenant 2 that "references" patient in tenant 1
1777        let observation = backend
1778            .create(
1779                &tenant2,
1780                "Observation",
1781                json!({
1782                    "id": "o1",
1783                    "subject": {"reference": "Patient/p1"}
1784                }),
1785                FhirVersion::default(),
1786            )
1787            .await
1788            .unwrap();
1789
1790        let include = IncludeDirective {
1791            include_type: crate::types::IncludeType::Include,
1792            source_type: "Observation".to_string(),
1793            search_param: "subject".to_string(),
1794            target_type: None,
1795            iterate: false,
1796        };
1797
1798        // Should NOT include the patient from tenant 1
1799        let included = backend
1800            .resolve_includes(&tenant2, &[observation], &[include])
1801            .await
1802            .unwrap();
1803
1804        assert!(included.is_empty());
1805    }
1806
1807    // ========================================================================
1808    // RevincludeProvider Tests
1809    // ========================================================================
1810
1811    #[tokio::test]
1812    async fn test_resolve_revincludes_basic() {
1813        let backend = create_test_backend();
1814        let tenant = create_test_tenant();
1815
1816        // Create a patient
1817        let patient = backend
1818            .create(
1819                &tenant,
1820                "Patient",
1821                json!({"id": "p1"}),
1822                FhirVersion::default(),
1823            )
1824            .await
1825            .unwrap();
1826
1827        // Create observations that reference the patient
1828        backend
1829            .create(
1830                &tenant,
1831                "Observation",
1832                json!({
1833                    "id": "o1",
1834                    "subject": {"reference": "Patient/p1"}
1835                }),
1836                FhirVersion::default(),
1837            )
1838            .await
1839            .unwrap();
1840        backend
1841            .create(
1842                &tenant,
1843                "Observation",
1844                json!({
1845                    "id": "o2",
1846                    "subject": {"reference": "Patient/p1"}
1847                }),
1848                FhirVersion::default(),
1849            )
1850            .await
1851            .unwrap();
1852
1853        // Also create an observation for a different patient
1854        backend
1855            .create(
1856                &tenant,
1857                "Observation",
1858                json!({
1859                    "id": "o3",
1860                    "subject": {"reference": "Patient/p2"}
1861                }),
1862                FhirVersion::default(),
1863            )
1864            .await
1865            .unwrap();
1866
1867        let revinclude = IncludeDirective {
1868            include_type: crate::types::IncludeType::Revinclude,
1869            source_type: "Observation".to_string(),
1870            search_param: "subject".to_string(),
1871            target_type: None,
1872            iterate: false,
1873        };
1874
1875        let included = backend
1876            .resolve_revincludes(&tenant, &[patient], &[revinclude])
1877            .await
1878            .unwrap();
1879
1880        // Should include 2 observations
1881        assert_eq!(included.len(), 2);
1882        assert!(included.iter().all(|r| r.resource_type() == "Observation"));
1883        let ids: Vec<&str> = included.iter().map(|r| r.id()).collect();
1884        assert!(ids.contains(&"o1"));
1885        assert!(ids.contains(&"o2"));
1886    }
1887
1888    #[tokio::test]
1889    async fn test_resolve_revincludes_empty() {
1890        let backend = create_test_backend();
1891        let tenant = create_test_tenant();
1892
1893        let patient = backend
1894            .create(
1895                &tenant,
1896                "Patient",
1897                json!({"id": "p1"}),
1898                FhirVersion::default(),
1899            )
1900            .await
1901            .unwrap();
1902
1903        let revinclude = IncludeDirective {
1904            include_type: crate::types::IncludeType::Revinclude,
1905            source_type: "Observation".to_string(),
1906            search_param: "subject".to_string(),
1907            target_type: None,
1908            iterate: false,
1909        };
1910
1911        // No observations exist
1912        let included = backend
1913            .resolve_revincludes(&tenant, &[patient], &[revinclude])
1914            .await
1915            .unwrap();
1916
1917        assert!(included.is_empty());
1918    }
1919
1920    // ========================================================================
1921    // ChainedSearchProvider Tests
1922    // ========================================================================
1923
1924    #[tokio::test]
1925    async fn test_resolve_chain_simple() {
1926        let backend = create_test_backend();
1927        let tenant = create_test_tenant();
1928        let tenant_id = tenant.tenant_id().as_str();
1929
1930        // Create patients
1931        backend
1932            .create(
1933                &tenant,
1934                "Patient",
1935                json!({"id": "p1", "name": [{"family": "Smith"}]}),
1936                FhirVersion::default(),
1937            )
1938            .await
1939            .unwrap();
1940        backend
1941            .create(
1942                &tenant,
1943                "Patient",
1944                json!({"id": "p2", "name": [{"family": "Jones"}]}),
1945                FhirVersion::default(),
1946            )
1947            .await
1948            .unwrap();
1949
1950        // Create observations
1951        backend
1952            .create(
1953                &tenant,
1954                "Observation",
1955                json!({
1956                    "id": "o1",
1957                    "subject": {"reference": "Patient/p1"}
1958                }),
1959                FhirVersion::default(),
1960            )
1961            .await
1962            .unwrap();
1963        backend
1964            .create(
1965                &tenant,
1966                "Observation",
1967                json!({
1968                    "id": "o2",
1969                    "subject": {"reference": "Patient/p2"}
1970                }),
1971                FhirVersion::default(),
1972            )
1973            .await
1974            .unwrap();
1975
1976        // Manually insert search index entries since FHIRPath extraction
1977        // may not fully populate them due to unsupported functions
1978        {
1979            let conn = backend.get_connection().unwrap();
1980            // Insert patient names
1981            conn.execute(
1982                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_string)
1983                 VALUES (?1, 'Patient', 'p1', 'name', 'Smith')",
1984                params![tenant_id],
1985            ).unwrap();
1986            conn.execute(
1987                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_string)
1988                 VALUES (?1, 'Patient', 'p2', 'name', 'Jones')",
1989                params![tenant_id],
1990            ).unwrap();
1991            // Insert observation subject references
1992            conn.execute(
1993                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
1994                 VALUES (?1, 'Observation', 'o1', 'subject', 'Patient/p1')",
1995                params![tenant_id],
1996            ).unwrap();
1997            conn.execute(
1998                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
1999                 VALUES (?1, 'Observation', 'o2', 'subject', 'Patient/p2')",
2000                params![tenant_id],
2001            ).unwrap();
2002        }
2003
2004        // Find observations where patient.name contains "Smith"
2005        let matching_ids = backend
2006            .resolve_chain(&tenant, "Observation", "subject.name", "Smith")
2007            .await
2008            .unwrap();
2009
2010        assert_eq!(matching_ids.len(), 1);
2011        assert!(matching_ids.contains(&"o1".to_string()));
2012    }
2013
2014    #[tokio::test]
2015    async fn test_resolve_chain_no_match() {
2016        let backend = create_test_backend();
2017        let tenant = create_test_tenant();
2018        let tenant_id = tenant.tenant_id().as_str();
2019
2020        // Create patient
2021        backend
2022            .create(
2023                &tenant,
2024                "Patient",
2025                json!({"id": "p1", "name": [{"family": "Smith"}]}),
2026                FhirVersion::default(),
2027            )
2028            .await
2029            .unwrap();
2030
2031        // Create observation
2032        backend
2033            .create(
2034                &tenant,
2035                "Observation",
2036                json!({
2037                    "id": "o1",
2038                    "subject": {"reference": "Patient/p1"}
2039                }),
2040                FhirVersion::default(),
2041            )
2042            .await
2043            .unwrap();
2044
2045        // Manually insert search index entries
2046        {
2047            let conn = backend.get_connection().unwrap();
2048            conn.execute(
2049                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_string)
2050                 VALUES (?1, 'Patient', 'p1', 'name', 'Smith')",
2051                params![tenant_id],
2052            ).unwrap();
2053            conn.execute(
2054                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2055                 VALUES (?1, 'Observation', 'o1', 'subject', 'Patient/p1')",
2056                params![tenant_id],
2057            ).unwrap();
2058        }
2059
2060        // Search for non-existent name
2061        let matching_ids = backend
2062            .resolve_chain(&tenant, "Observation", "subject.name", "Nonexistent")
2063            .await
2064            .unwrap();
2065
2066        assert!(matching_ids.is_empty());
2067    }
2068
2069    #[tokio::test]
2070    async fn test_resolve_reverse_chain() {
2071        let backend = create_test_backend();
2072        let tenant = create_test_tenant();
2073        let tenant_id = tenant.tenant_id().as_str();
2074
2075        // Create patients
2076        backend
2077            .create(
2078                &tenant,
2079                "Patient",
2080                json!({"id": "p1"}),
2081                FhirVersion::default(),
2082            )
2083            .await
2084            .unwrap();
2085        backend
2086            .create(
2087                &tenant,
2088                "Patient",
2089                json!({"id": "p2"}),
2090                FhirVersion::default(),
2091            )
2092            .await
2093            .unwrap();
2094
2095        // Create observations with codes
2096        backend
2097            .create(
2098                &tenant,
2099                "Observation",
2100                json!({
2101                    "id": "o1",
2102                    "subject": {"reference": "Patient/p1"},
2103                    "code": {"coding": [{"code": "8867-4"}]}
2104                }),
2105                FhirVersion::default(),
2106            )
2107            .await
2108            .unwrap();
2109        backend
2110            .create(
2111                &tenant,
2112                "Observation",
2113                json!({
2114                    "id": "o2",
2115                    "subject": {"reference": "Patient/p2"},
2116                    "code": {"coding": [{"code": "other"}]}
2117                }),
2118                FhirVersion::default(),
2119            )
2120            .await
2121            .unwrap();
2122
2123        // Manually insert search index entries since FHIRPath extraction
2124        // may not fully populate them due to unsupported functions
2125        {
2126            let conn = backend.get_connection().unwrap();
2127            // Insert subject references for observations
2128            conn.execute(
2129                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2130                 VALUES (?1, 'Observation', 'o1', 'subject', 'Patient/p1')",
2131                params![tenant_id],
2132            ).unwrap();
2133            conn.execute(
2134                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2135                 VALUES (?1, 'Observation', 'o2', 'subject', 'Patient/p2')",
2136                params![tenant_id],
2137            ).unwrap();
2138            // Insert code tokens for observations
2139            conn.execute(
2140                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_code)
2141                 VALUES (?1, 'Observation', 'o1', 'code', '8867-4')",
2142                params![tenant_id],
2143            ).unwrap();
2144            conn.execute(
2145                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_code)
2146                 VALUES (?1, 'Observation', 'o2', 'code', 'other')",
2147                params![tenant_id],
2148            ).unwrap();
2149        }
2150
2151        // _has:Observation:subject:code=8867-4
2152        let reverse_chain = ReverseChainedParameter::terminal(
2153            "Observation",
2154            "subject",
2155            "code",
2156            crate::types::SearchValue::eq("8867-4"),
2157        );
2158
2159        let matching_ids = backend
2160            .resolve_reverse_chain(&tenant, "Patient", &reverse_chain)
2161            .await
2162            .unwrap();
2163
2164        // Should find p1 (referenced by observation with code 8867-4)
2165        assert_eq!(matching_ids.len(), 1);
2166        assert!(matching_ids.contains(&"p1".to_string()));
2167    }
2168
2169    #[tokio::test]
2170    async fn test_resolve_chain_multi_level() {
2171        // Test 3-level chain: Observation?subject.organization.name=Hospital
2172        let backend = create_test_backend();
2173        let tenant = create_test_tenant();
2174        let tenant_id = tenant.tenant_id().as_str();
2175
2176        // Create organization
2177        backend
2178            .create(
2179                &tenant,
2180                "Organization",
2181                json!({"id": "org1", "name": "General Hospital"}),
2182                FhirVersion::default(),
2183            )
2184            .await
2185            .unwrap();
2186
2187        // Create patient linked to organization
2188        backend
2189            .create(
2190                &tenant,
2191                "Patient",
2192                json!({"id": "p1", "managingOrganization": {"reference": "Organization/org1"}}),
2193                FhirVersion::default(),
2194            )
2195            .await
2196            .unwrap();
2197
2198        // Create observation linked to patient
2199        backend
2200            .create(
2201                &tenant,
2202                "Observation",
2203                json!({"id": "o1", "subject": {"reference": "Patient/p1"}}),
2204                FhirVersion::default(),
2205            )
2206            .await
2207            .unwrap();
2208
2209        // Manually insert search index entries
2210        {
2211            let conn = backend.get_connection().unwrap();
2212            // Organization name
2213            conn.execute(
2214                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_string)
2215                 VALUES (?1, 'Organization', 'org1', 'name', 'General Hospital')",
2216                params![tenant_id],
2217            ).unwrap();
2218            // Patient -> Organization reference
2219            conn.execute(
2220                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2221                 VALUES (?1, 'Patient', 'p1', 'organization', 'Organization/org1')",
2222                params![tenant_id],
2223            ).unwrap();
2224            // Observation -> Patient reference
2225            conn.execute(
2226                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2227                 VALUES (?1, 'Observation', 'o1', 'subject', 'Patient/p1')",
2228                params![tenant_id],
2229            ).unwrap();
2230        }
2231
2232        // Find observations where patient's organization name contains "Hospital"
2233        let matching_ids = backend
2234            .resolve_chain(
2235                &tenant,
2236                "Observation",
2237                "subject.organization.name",
2238                "Hospital",
2239            )
2240            .await
2241            .unwrap();
2242
2243        assert_eq!(matching_ids.len(), 1);
2244        assert!(matching_ids.contains(&"o1".to_string()));
2245    }
2246
2247    #[tokio::test]
2248    async fn test_resolve_chain_with_type_modifier() {
2249        // Test chain with explicit type: Observation?subject:Patient.name=Smith
2250        let backend = create_test_backend();
2251        let tenant = create_test_tenant();
2252        let tenant_id = tenant.tenant_id().as_str();
2253
2254        // Create patient
2255        backend
2256            .create(
2257                &tenant,
2258                "Patient",
2259                json!({"id": "p1", "name": [{"family": "Smith"}]}),
2260                FhirVersion::default(),
2261            )
2262            .await
2263            .unwrap();
2264
2265        // Create observation
2266        backend
2267            .create(
2268                &tenant,
2269                "Observation",
2270                json!({"id": "o1", "subject": {"reference": "Patient/p1"}}),
2271                FhirVersion::default(),
2272            )
2273            .await
2274            .unwrap();
2275
2276        // Manually insert search index entries
2277        {
2278            let conn = backend.get_connection().unwrap();
2279            conn.execute(
2280                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_string)
2281                 VALUES (?1, 'Patient', 'p1', 'name', 'Smith')",
2282                params![tenant_id],
2283            ).unwrap();
2284            conn.execute(
2285                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2286                 VALUES (?1, 'Observation', 'o1', 'subject', 'Patient/p1')",
2287                params![tenant_id],
2288            ).unwrap();
2289        }
2290
2291        // Use explicit type modifier
2292        let matching_ids = backend
2293            .resolve_chain(&tenant, "Observation", "subject:Patient.name", "Smith")
2294            .await
2295            .unwrap();
2296
2297        assert_eq!(matching_ids.len(), 1);
2298        assert!(matching_ids.contains(&"o1".to_string()));
2299    }
2300
2301    #[tokio::test]
2302    async fn test_chain_invalid_param_error() {
2303        // Test that invalid chain parameters return an error
2304        let backend = create_test_backend();
2305        let tenant = create_test_tenant();
2306
2307        // Try a chain with non-existent parameter
2308        let result = backend
2309            .resolve_chain(&tenant, "Observation", "invalid.param", "value")
2310            .await;
2311
2312        // Should return an error due to unknown parameter
2313        assert!(result.is_err());
2314    }
2315
2316    // ========================================================================
2317    // Helper Method Tests
2318    // ========================================================================
2319
2320    #[test]
2321    fn test_parse_reference_simple() {
2322        let backend = SqliteBackend::in_memory().unwrap();
2323
2324        let result = backend.parse_reference("Patient/123");
2325        assert_eq!(result, Some(("Patient".to_string(), "123".to_string())));
2326    }
2327
2328    #[test]
2329    fn test_parse_reference_url() {
2330        let backend = SqliteBackend::in_memory().unwrap();
2331
2332        let result = backend.parse_reference("http://example.com/fhir/Patient/456");
2333        assert_eq!(result, Some(("Patient".to_string(), "456".to_string())));
2334    }
2335
2336    // ========================================================================
2337    // Token Search with system|code Tests
2338    // ========================================================================
2339
2340    #[tokio::test]
2341    async fn test_token_search_system_and_code() {
2342        let backend = create_test_backend();
2343        let tenant = create_test_tenant();
2344        let tenant_id = tenant.tenant_id().as_str();
2345
2346        // Create two DocumentReferences with different type codes
2347        backend
2348            .create(
2349                &tenant,
2350                "DocumentReference",
2351                json!({
2352                    "resourceType": "DocumentReference",
2353                    "id": "doc1",
2354                    "status": "current",
2355                    "type": {
2356                        "coding": [{
2357                            "system": "http://loinc.org",
2358                            "code": "86533-7",
2359                            "display": "Patient Living will"
2360                        }]
2361                    },
2362                    "subject": {"reference": "Patient/p1"}
2363                }),
2364                FhirVersion::default(),
2365            )
2366            .await
2367            .unwrap();
2368
2369        backend
2370            .create(
2371                &tenant,
2372                "DocumentReference",
2373                json!({
2374                    "resourceType": "DocumentReference",
2375                    "id": "doc2",
2376                    "status": "current",
2377                    "type": {
2378                        "coding": [{
2379                            "system": "http://loinc.org",
2380                            "code": "34117-2",
2381                            "display": "History and physical note"
2382                        }]
2383                    },
2384                    "subject": {"reference": "Patient/p1"}
2385                }),
2386                FhirVersion::default(),
2387            )
2388            .await
2389            .unwrap();
2390
2391        // Insert token search index entries for both documents
2392        {
2393            let conn = backend.get_connection().unwrap();
2394            conn.execute(
2395                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2396                 VALUES (?1, 'DocumentReference', 'doc1', 'type', 'http://loinc.org', '86533-7')",
2397                params![tenant_id],
2398            ).unwrap();
2399            conn.execute(
2400                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2401                 VALUES (?1, 'DocumentReference', 'doc2', 'type', 'http://loinc.org', '34117-2')",
2402                params![tenant_id],
2403            ).unwrap();
2404        }
2405
2406        // Search with system|code: should find only doc1
2407        let mut query = SearchQuery::new("DocumentReference");
2408        query.parameters.push(SearchParameter {
2409            name: "type".to_string(),
2410            param_type: crate::types::SearchParamType::Token,
2411            modifier: None,
2412            values: vec![SearchValue::eq("http://loinc.org|86533-7")],
2413            chain: vec![],
2414            components: vec![],
2415        });
2416
2417        let result = backend.search(&tenant, &query).await.unwrap();
2418
2419        assert_eq!(
2420            result.resources.items.len(),
2421            1,
2422            "Should find exactly 1 DocumentReference with type http://loinc.org|86533-7"
2423        );
2424        assert_eq!(result.resources.items[0].id(), "doc1");
2425    }
2426
2427    #[tokio::test]
2428    async fn test_token_search_code_only() {
2429        let backend = create_test_backend();
2430        let tenant = create_test_tenant();
2431        let tenant_id = tenant.tenant_id().as_str();
2432
2433        // Create a DocumentReference
2434        backend
2435            .create(
2436                &tenant,
2437                "DocumentReference",
2438                json!({
2439                    "resourceType": "DocumentReference",
2440                    "id": "doc1",
2441                    "status": "current",
2442                    "type": {
2443                        "coding": [{
2444                            "system": "http://loinc.org",
2445                            "code": "86533-7"
2446                        }]
2447                    }
2448                }),
2449                FhirVersion::default(),
2450            )
2451            .await
2452            .unwrap();
2453
2454        // Insert token search index entry
2455        {
2456            let conn = backend.get_connection().unwrap();
2457            conn.execute(
2458                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2459                 VALUES (?1, 'DocumentReference', 'doc1', 'type', 'http://loinc.org', '86533-7')",
2460                params![tenant_id],
2461            ).unwrap();
2462        }
2463
2464        // Search with code only (no system): should still find doc1
2465        let mut query = SearchQuery::new("DocumentReference");
2466        query.parameters.push(SearchParameter {
2467            name: "type".to_string(),
2468            param_type: crate::types::SearchParamType::Token,
2469            modifier: None,
2470            values: vec![SearchValue::eq("86533-7")],
2471            chain: vec![],
2472            components: vec![],
2473        });
2474
2475        let result = backend.search(&tenant, &query).await.unwrap();
2476
2477        assert_eq!(
2478            result.resources.items.len(),
2479            1,
2480            "Code-only search should find the document regardless of system"
2481        );
2482        assert_eq!(result.resources.items[0].id(), "doc1");
2483    }
2484
2485    #[tokio::test]
2486    async fn test_token_search_wrong_system() {
2487        let backend = create_test_backend();
2488        let tenant = create_test_tenant();
2489        let tenant_id = tenant.tenant_id().as_str();
2490
2491        // Create a DocumentReference
2492        backend
2493            .create(
2494                &tenant,
2495                "DocumentReference",
2496                json!({
2497                    "resourceType": "DocumentReference",
2498                    "id": "doc1",
2499                    "status": "current"
2500                }),
2501                FhirVersion::default(),
2502            )
2503            .await
2504            .unwrap();
2505
2506        // Insert token with loinc.org system
2507        {
2508            let conn = backend.get_connection().unwrap();
2509            conn.execute(
2510                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2511                 VALUES (?1, 'DocumentReference', 'doc1', 'type', 'http://loinc.org', '86533-7')",
2512                params![tenant_id],
2513            ).unwrap();
2514        }
2515
2516        // Search with wrong system: should return 0
2517        let mut query = SearchQuery::new("DocumentReference");
2518        query.parameters.push(SearchParameter {
2519            name: "type".to_string(),
2520            param_type: crate::types::SearchParamType::Token,
2521            modifier: None,
2522            values: vec![SearchValue::eq("http://snomed.info/sct|86533-7")],
2523            chain: vec![],
2524            components: vec![],
2525        });
2526
2527        let result = backend.search(&tenant, &query).await.unwrap();
2528
2529        assert_eq!(
2530            result.resources.items.len(),
2531            0,
2532            "Search with wrong system should return no results"
2533        );
2534    }
2535
2536    #[tokio::test]
2537    async fn test_token_search_combined_with_reference() {
2538        let backend = create_test_backend();
2539        let tenant = create_test_tenant();
2540        let tenant_id = tenant.tenant_id().as_str();
2541
2542        // Create two DocumentReferences for different patients
2543        backend
2544            .create(
2545                &tenant,
2546                "DocumentReference",
2547                json!({
2548                    "resourceType": "DocumentReference",
2549                    "id": "doc1",
2550                    "status": "current",
2551                    "type": {
2552                        "coding": [{"system": "http://loinc.org", "code": "86533-7"}]
2553                    },
2554                    "subject": {"reference": "Patient/p1"}
2555                }),
2556                FhirVersion::default(),
2557            )
2558            .await
2559            .unwrap();
2560
2561        backend
2562            .create(
2563                &tenant,
2564                "DocumentReference",
2565                json!({
2566                    "resourceType": "DocumentReference",
2567                    "id": "doc2",
2568                    "status": "current",
2569                    "type": {
2570                        "coding": [{"system": "http://loinc.org", "code": "86533-7"}]
2571                    },
2572                    "subject": {"reference": "Patient/p2"}
2573                }),
2574                FhirVersion::default(),
2575            )
2576            .await
2577            .unwrap();
2578
2579        // Insert search index entries for both
2580        {
2581            let conn = backend.get_connection().unwrap();
2582            // Type tokens
2583            conn.execute(
2584                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2585                 VALUES (?1, 'DocumentReference', 'doc1', 'type', 'http://loinc.org', '86533-7')",
2586                params![tenant_id],
2587            ).unwrap();
2588            conn.execute(
2589                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_token_system, value_token_code)
2590                 VALUES (?1, 'DocumentReference', 'doc2', 'type', 'http://loinc.org', '86533-7')",
2591                params![tenant_id],
2592            ).unwrap();
2593            // Patient references
2594            conn.execute(
2595                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2596                 VALUES (?1, 'DocumentReference', 'doc1', 'patient', 'Patient/p1')",
2597                params![tenant_id],
2598            ).unwrap();
2599            conn.execute(
2600                "INSERT INTO search_index (tenant_id, resource_type, resource_id, param_name, value_reference)
2601                 VALUES (?1, 'DocumentReference', 'doc2', 'patient', 'Patient/p2')",
2602                params![tenant_id],
2603            ).unwrap();
2604        }
2605
2606        // Search with both patient AND type (system|code)
2607        let mut query = SearchQuery::new("DocumentReference");
2608        query.parameters.push(SearchParameter {
2609            name: "patient".to_string(),
2610            param_type: crate::types::SearchParamType::Reference,
2611            modifier: None,
2612            values: vec![SearchValue::eq("p1")],
2613            chain: vec![],
2614            components: vec![],
2615        });
2616        query.parameters.push(SearchParameter {
2617            name: "type".to_string(),
2618            param_type: crate::types::SearchParamType::Token,
2619            modifier: None,
2620            values: vec![SearchValue::eq("http://loinc.org|86533-7")],
2621            chain: vec![],
2622            components: vec![],
2623        });
2624
2625        let result = backend.search(&tenant, &query).await.unwrap();
2626
2627        assert_eq!(
2628            result.resources.items.len(),
2629            1,
2630            "Combined patient + type search should find exactly doc1"
2631        );
2632        assert_eq!(result.resources.items[0].id(), "doc1");
2633    }
2634
2635    // ---- _contained / _containedType search ----
2636
2637    use crate::types::{ContainedMode, ContainedReturn, SearchParamType, SearchValue};
2638
2639    /// Seeds an Observation containing a Patient named "Smith", plus a top-level
2640    /// Patient also named "Smith".
2641    async fn seed_contained(backend: &SqliteBackend, tenant: &TenantContext) {
2642        backend
2643            .create(
2644                tenant,
2645                "Observation",
2646                json!({
2647                    "resourceType": "Observation",
2648                    "id": "obs1",
2649                    "status": "final",
2650                    "code": { "coding": [{ "system": "http://loinc.org", "code": "1234-5" }] },
2651                    "subject": { "reference": "#p1" },
2652                    "contained": [{
2653                        "resourceType": "Patient",
2654                        "id": "p1",
2655                        "name": [{ "family": "Smith", "given": ["Contained"] }],
2656                        "gender": "male"
2657                    }]
2658                }),
2659                helios_fhir::FhirVersion::default(),
2660            )
2661            .await
2662            .unwrap();
2663        backend
2664            .create(
2665                tenant,
2666                "Patient",
2667                json!({ "resourceType": "Patient", "id": "top1", "name": [{ "family": "Smith" }] }),
2668                helios_fhir::FhirVersion::default(),
2669            )
2670            .await
2671            .unwrap();
2672    }
2673
2674    fn name_query(mode: ContainedMode, ret: ContainedReturn) -> SearchQuery {
2675        let mut q = SearchQuery::new("Patient");
2676        q.contained = mode;
2677        q.contained_return = ret;
2678        q.parameters.push(SearchParameter {
2679            name: "name".to_string(),
2680            param_type: SearchParamType::String,
2681            modifier: None,
2682            values: vec![SearchValue::eq("Smith")],
2683            chain: vec![],
2684            components: vec![],
2685        });
2686        q
2687    }
2688
2689    #[tokio::test]
2690    async fn contained_off_excludes_contained_resources() {
2691        let backend = create_test_backend();
2692        let tenant = create_test_tenant();
2693        seed_contained(&backend, &tenant).await;
2694
2695        // Default (_contained=off): only the top-level Patient matches.
2696        let result = backend
2697            .search(
2698                &tenant,
2699                &name_query(ContainedMode::Off, ContainedReturn::Container),
2700            )
2701            .await
2702            .unwrap();
2703        let urls: Vec<String> = result.resources.items.iter().map(|r| r.url()).collect();
2704        assert_eq!(urls, vec!["Patient/top1"]);
2705    }
2706
2707    #[tokio::test]
2708    async fn contained_on_returns_container_by_default() {
2709        let backend = create_test_backend();
2710        let tenant = create_test_tenant();
2711        seed_contained(&backend, &tenant).await;
2712
2713        let result = backend
2714            .search(
2715                &tenant,
2716                &name_query(ContainedMode::On, ContainedReturn::Container),
2717            )
2718            .await
2719            .unwrap();
2720        let urls: Vec<String> = result.resources.items.iter().map(|r| r.url()).collect();
2721        assert_eq!(urls, vec!["Observation/obs1"], "container is returned");
2722    }
2723
2724    #[tokio::test]
2725    async fn contained_on_contained_type_returns_contained_resource() {
2726        let backend = create_test_backend();
2727        let tenant = create_test_tenant();
2728        seed_contained(&backend, &tenant).await;
2729
2730        let result = backend
2731            .search(
2732                &tenant,
2733                &name_query(ContainedMode::On, ContainedReturn::Contained),
2734            )
2735            .await
2736            .unwrap();
2737        assert_eq!(result.resources.items.len(), 1);
2738        let r = &result.resources.items[0];
2739        assert_eq!(r.resource_type(), "Patient");
2740        assert_eq!(r.id(), "p1");
2741        assert_eq!(r.content()["name"][0]["given"][0], "Contained");
2742    }
2743
2744    #[tokio::test]
2745    async fn contained_both_merges_top_level_and_container() {
2746        let backend = create_test_backend();
2747        let tenant = create_test_tenant();
2748        seed_contained(&backend, &tenant).await;
2749
2750        let result = backend
2751            .search(
2752                &tenant,
2753                &name_query(ContainedMode::Both, ContainedReturn::Container),
2754            )
2755            .await
2756            .unwrap();
2757        let mut urls: Vec<String> = result.resources.items.iter().map(|r| r.url()).collect();
2758        urls.sort();
2759        assert_eq!(urls, vec!["Observation/obs1", "Patient/top1"]);
2760    }
2761
2762    #[tokio::test]
2763    async fn contained_respects_param_so_no_false_match() {
2764        let backend = create_test_backend();
2765        let tenant = create_test_tenant();
2766        seed_contained(&backend, &tenant).await;
2767
2768        // No contained Patient named "Jones" → no container match.
2769        let mut q = name_query(ContainedMode::On, ContainedReturn::Container);
2770        q.parameters[0].values = vec![SearchValue::eq("Jones")];
2771        let result = backend.search(&tenant, &q).await.unwrap();
2772        assert!(result.resources.items.is_empty());
2773    }
2774}