Skip to main content

helios_persistence/backends/sqlite/
backend.rs

1//! SQLite backend implementation.
2
3use std::fmt::Debug;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use async_trait::async_trait;
9use parking_lot::RwLock;
10use r2d2::{Pool, PooledConnection};
11use r2d2_sqlite::SqliteConnectionManager;
12use serde::{Deserialize, Serialize};
13
14use helios_fhir::FhirVersion;
15
16use crate::core::{Backend, BackendCapability, BackendKind};
17use crate::error::{BackendError, StorageResult};
18use crate::search::{SearchParameterExtractor, SearchParameterLoader, SearchParameterRegistry};
19
20use super::schema;
21
22/// Counter for generating unique in-memory database names.
23static MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
24
25/// SQLite backend for FHIR resource storage.
26pub struct SqliteBackend {
27    pool: Pool<SqliteConnectionManager>,
28    config: SqliteBackendConfig,
29    is_memory: bool,
30    /// Search parameter registry (in-memory cache of active parameters).
31    search_registry: Arc<RwLock<SearchParameterRegistry>>,
32    /// Extractor for deriving searchable values from resources.
33    search_extractor: Arc<SearchParameterExtractor>,
34}
35
36impl Debug for SqliteBackend {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("SqliteBackend")
39            .field("config", &self.config)
40            .field("is_memory", &self.is_memory)
41            .field("search_registry_len", &self.search_registry.read().len())
42            .finish_non_exhaustive()
43    }
44}
45
46/// Configuration for the SQLite backend.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SqliteBackendConfig {
49    /// Maximum number of connections in the pool.
50    #[serde(default = "default_max_connections")]
51    pub max_connections: u32,
52
53    /// Minimum number of idle connections.
54    #[serde(default = "default_min_connections")]
55    pub min_connections: u32,
56
57    /// Connection timeout in milliseconds.
58    #[serde(default = "default_connection_timeout_ms")]
59    pub connection_timeout_ms: u64,
60
61    /// SQLite busy timeout in milliseconds.
62    #[serde(default = "default_busy_timeout_ms")]
63    pub busy_timeout_ms: u32,
64
65    /// Enable WAL mode for better concurrency.
66    #[serde(default = "default_true")]
67    pub enable_wal: bool,
68
69    /// Enable foreign key constraints.
70    #[serde(default = "default_true")]
71    pub enable_foreign_keys: bool,
72
73    /// FHIR version for this backend instance.
74    /// Used to load the appropriate SearchParameter definitions.
75    #[serde(default = "crate::default_fhir_version")]
76    pub fhir_version: FhirVersion,
77
78    /// Directory containing FHIR SearchParameter spec files.
79    /// If None, defaults to "./data" or the directory containing the executable.
80    #[serde(default)]
81    pub data_dir: Option<PathBuf>,
82
83    /// When true, search indexing is offloaded to a secondary backend (e.g., Elasticsearch).
84    /// The SQLite search_index and resource_fts tables will not be populated.
85    #[serde(default)]
86    pub search_offloaded: bool,
87}
88
89fn default_max_connections() -> u32 {
90    10
91}
92
93fn default_min_connections() -> u32 {
94    1
95}
96
97fn default_connection_timeout_ms() -> u64 {
98    30000
99}
100
101fn default_busy_timeout_ms() -> u32 {
102    5000
103}
104
105fn default_true() -> bool {
106    true
107}
108
109impl Default for SqliteBackendConfig {
110    fn default() -> Self {
111        Self {
112            max_connections: default_max_connections(),
113            min_connections: default_min_connections(),
114            connection_timeout_ms: default_connection_timeout_ms(),
115            busy_timeout_ms: default_busy_timeout_ms(),
116            enable_wal: true,
117            enable_foreign_keys: true,
118            fhir_version: FhirVersion::default_enabled(),
119            data_dir: None,
120            search_offloaded: false,
121        }
122    }
123}
124
125impl SqliteBackend {
126    /// Creates a new in-memory SQLite backend.
127    pub fn in_memory() -> StorageResult<Self> {
128        Self::with_config(":memory:", SqliteBackendConfig::default())
129    }
130
131    /// Opens or creates a file-based SQLite database.
132    pub fn open<P: AsRef<Path>>(path: P) -> StorageResult<Self> {
133        Self::with_config(path, SqliteBackendConfig::default())
134    }
135
136    /// Creates a backend with custom configuration.
137    pub fn with_config<P: AsRef<Path>>(
138        path: P,
139        config: SqliteBackendConfig,
140    ) -> StorageResult<Self> {
141        let path_str = path.as_ref().to_string_lossy();
142        let is_memory = path_str == ":memory:";
143
144        // For in-memory databases with connection pools, we need to use a shared-cache
145        // URI so all connections access the same database. Otherwise, each connection
146        // gets its own isolated in-memory database. Each backend instance gets a unique
147        // database name to ensure test isolation.
148        let manager = if is_memory {
149            let db_id = MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
150            let uri = format!("file:hfs_mem_{}?mode=memory&cache=shared", db_id);
151            SqliteConnectionManager::file(uri)
152        } else {
153            SqliteConnectionManager::file(path.as_ref())
154        };
155        // Per-connection initialiser: register the in-DB SOF runner's helper
156        // UDFs (`fhir_last_segment`) so SQL emitted by the FHIRPath compiler
157        // can call them directly without dialect-specific shimming.
158        let manager = manager.with_init(|conn| {
159            crate::sof::sqlite_udfs::register(conn).map_err(|e| {
160                rusqlite::Error::SqliteFailure(
161                    rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
162                    Some(format!("failed to register SOF SQLite UDFs: {e}")),
163                )
164            })
165        });
166
167        let pool = Pool::builder()
168            .max_size(config.max_connections)
169            .min_idle(Some(config.min_connections))
170            .connection_timeout(std::time::Duration::from_millis(
171                config.connection_timeout_ms,
172            ))
173            .build(manager)
174            .map_err(|e| {
175                crate::error::StorageError::Backend(BackendError::ConnectionFailed {
176                    backend_name: "sqlite".to_string(),
177                    message: e.to_string(),
178                })
179            })?;
180
181        // Initialize the search parameter registry
182        let search_registry = Arc::new(RwLock::new(SearchParameterRegistry::new()));
183        {
184            let loader = SearchParameterLoader::new(config.fhir_version);
185            let mut registry = search_registry.write();
186
187            // Track counts and sources for summary
188            let mut fallback_count = 0;
189            let mut spec_count = 0;
190            let mut spec_file: Option<PathBuf> = None;
191            let mut custom_count = 0;
192            let mut custom_files: Vec<String> = Vec::new();
193
194            // 1. Load minimal embedded fallback params (always available)
195            match loader.load_embedded() {
196                Ok(params) => {
197                    for param in params {
198                        if registry.register(param).is_ok() {
199                            fallback_count += 1;
200                        }
201                    }
202                }
203                Err(e) => {
204                    tracing::error!("Failed to load embedded SearchParameters: {}", e);
205                }
206            }
207
208            // 2. Load spec file params (may fail if file missing)
209            let data_dir = config
210                .data_dir
211                .clone()
212                .unwrap_or_else(|| PathBuf::from("./data"));
213            let spec_filename = loader.spec_filename();
214            let spec_path = data_dir.join(spec_filename);
215            match loader.load_from_spec_file(&data_dir) {
216                Ok(params) => {
217                    for param in params {
218                        if registry.register(param).is_ok() {
219                            spec_count += 1;
220                        }
221                    }
222                    if spec_count > 0 {
223                        spec_file = Some(spec_path);
224                    }
225                }
226                Err(e) => {
227                    tracing::warn!(
228                        "Could not load spec SearchParameters from {}: {}. Using minimal fallback.",
229                        spec_path.display(),
230                        e
231                    );
232                }
233            }
234
235            // 3. Load custom SearchParameters from data directory (optional)
236            match loader.load_custom_from_directory_with_files(&data_dir) {
237                Ok((params, files)) => {
238                    for param in params {
239                        if registry.register(param).is_ok() {
240                            custom_count += 1;
241                        }
242                    }
243                    custom_files = files;
244                }
245                Err(e) => {
246                    tracing::warn!(
247                        "Error loading custom SearchParameters from {}: {}",
248                        data_dir.display(),
249                        e
250                    );
251                }
252            }
253
254            // Log summary
255            let resource_type_count = registry.resource_types().len();
256            let spec_info = spec_file
257                .map(|p| format!(" from {}", p.display()))
258                .unwrap_or_default();
259            let custom_info = if custom_files.is_empty() {
260                String::new()
261            } else {
262                format!(" [{}]", custom_files.join(", "))
263            };
264            tracing::info!(
265                "SearchParameter registry initialized: {} total ({} spec{}, {} fallback, {} custom{}) covering {} resource types",
266                registry.len(),
267                spec_count,
268                spec_info,
269                fallback_count,
270                custom_count,
271                custom_info,
272                resource_type_count
273            );
274        }
275        let search_extractor = Arc::new(SearchParameterExtractor::new(search_registry.clone()));
276
277        let backend = Self {
278            pool,
279            config,
280            is_memory,
281            search_registry,
282            search_extractor,
283        };
284
285        // Configure the connection
286        backend.configure_connection()?;
287
288        Ok(backend)
289    }
290
291    /// Initialize the database schema.
292    ///
293    /// This also loads any stored SearchParameter resources from the database
294    /// into the registry.
295    pub fn init_schema(&self) -> StorageResult<()> {
296        let conn = self.get_connection()?;
297        schema::initialize_schema(&conn)?;
298
299        // Load stored (POSTed) SearchParameters from database
300        let stored_count = self.load_stored_search_parameters()?;
301        if stored_count > 0 {
302            let registry = self.search_registry.read();
303            tracing::info!(
304                "Loaded {} stored SearchParameters from database (total now: {})",
305                stored_count,
306                registry.len()
307            );
308        }
309
310        Ok(())
311    }
312
313    /// Loads SearchParameter resources stored in the database into the registry.
314    ///
315    /// This is called during schema initialization to restore any custom
316    /// SearchParameters that were POSTed to the server.
317    fn load_stored_search_parameters(&self) -> StorageResult<usize> {
318        use crate::search::registry::{SearchParameterSource, SearchParameterStatus};
319
320        let conn = self.get_connection()?;
321        let mut stmt = conn
322            .prepare(
323                "SELECT data FROM resources WHERE resource_type = 'SearchParameter' AND is_deleted = 0",
324            )
325            .map_err(|e| {
326                crate::error::StorageError::Backend(BackendError::Internal {
327                    backend_name: "sqlite".to_string(),
328                    message: format!("Failed to prepare SearchParameter query: {}", e),
329                    source: None,
330                })
331            })?;
332
333        let loader = SearchParameterLoader::new(self.config.fhir_version);
334        let mut registry = self.search_registry.write();
335        let mut count = 0;
336
337        let rows = stmt
338            .query_map([], |row| row.get::<_, Vec<u8>>(0))
339            .map_err(|e| {
340                crate::error::StorageError::Backend(BackendError::Internal {
341                    backend_name: "sqlite".to_string(),
342                    message: format!("Failed to query SearchParameters: {}", e),
343                    source: None,
344                })
345            })?;
346
347        for row in rows {
348            let data = match row {
349                Ok(data) => data,
350                Err(e) => {
351                    tracing::warn!("Failed to read SearchParameter row: {}", e);
352                    continue;
353                }
354            };
355
356            let json: serde_json::Value = match serde_json::from_slice(&data) {
357                Ok(json) => json,
358                Err(e) => {
359                    tracing::warn!("Failed to parse SearchParameter JSON: {}", e);
360                    continue;
361                }
362            };
363
364            match loader.parse_resource(&json) {
365                Ok(mut def) => {
366                    // Only register active parameters
367                    if def.status == SearchParameterStatus::Active {
368                        def.source = SearchParameterSource::Stored;
369                        if registry.register(def).is_ok() {
370                            count += 1;
371                        }
372                    }
373                }
374                Err(e) => {
375                    tracing::warn!("Failed to parse stored SearchParameter: {}", e);
376                }
377            }
378        }
379
380        Ok(count)
381    }
382
383    /// Returns a clone of the connection pool (cheap — pool is `Arc`-backed internally).
384    pub(crate) fn pool(&self) -> Pool<SqliteConnectionManager> {
385        self.pool.clone()
386    }
387
388    /// Get a connection from the pool.
389    pub(crate) fn get_connection(
390        &self,
391    ) -> StorageResult<PooledConnection<SqliteConnectionManager>> {
392        self.pool.get().map_err(|e| {
393            crate::error::StorageError::Backend(BackendError::ConnectionFailed {
394                backend_name: "sqlite".to_string(),
395                message: e.to_string(),
396            })
397        })
398    }
399
400    /// Get the search parameter registry.
401    pub(crate) fn get_search_registry(&self) -> Arc<RwLock<SearchParameterRegistry>> {
402        Arc::clone(&self.search_registry)
403    }
404
405    /// Configure connection settings.
406    fn configure_connection(&self) -> StorageResult<()> {
407        let conn = self.get_connection()?;
408
409        conn.busy_timeout(std::time::Duration::from_millis(
410            self.config.busy_timeout_ms as u64,
411        ))
412        .map_err(|e| {
413            crate::error::StorageError::Backend(BackendError::Internal {
414                backend_name: "sqlite".to_string(),
415                message: format!("Failed to set busy timeout: {}", e),
416                source: None,
417            })
418        })?;
419
420        if self.config.enable_foreign_keys {
421            conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| {
422                crate::error::StorageError::Backend(BackendError::Internal {
423                    backend_name: "sqlite".to_string(),
424                    message: format!("Failed to enable foreign keys: {}", e),
425                    source: None,
426                })
427            })?;
428        }
429
430        if self.config.enable_wal && !self.is_memory {
431            // PRAGMA journal_mode returns a result, so we use query_row instead of execute
432            conn.query_row("PRAGMA journal_mode = WAL", [], |_row| Ok(()))
433                .map_err(|e| {
434                    crate::error::StorageError::Backend(BackendError::Internal {
435                        backend_name: "sqlite".to_string(),
436                        message: format!("Failed to enable WAL mode: {}", e),
437                        source: None,
438                    })
439                })?;
440        }
441
442        Ok(())
443    }
444
445    /// Returns whether this is an in-memory database.
446    pub fn is_memory(&self) -> bool {
447        self.is_memory
448    }
449
450    /// Returns the backend configuration.
451    pub fn config(&self) -> &SqliteBackendConfig {
452        &self.config
453    }
454
455    /// Returns a reference to the search parameter registry.
456    pub fn search_registry(&self) -> &Arc<RwLock<SearchParameterRegistry>> {
457        &self.search_registry
458    }
459
460    /// Returns a reference to the search parameter extractor.
461    pub fn search_extractor(&self) -> &Arc<SearchParameterExtractor> {
462        &self.search_extractor
463    }
464
465    /// Returns whether search indexing is offloaded to a secondary backend.
466    pub fn is_search_offloaded(&self) -> bool {
467        self.config.search_offloaded
468    }
469
470    /// Sets the search offloaded flag.
471    ///
472    /// When set to true, the SQLite backend will skip populating the
473    /// `search_index` and `resource_fts` tables, as search is handled
474    /// by a secondary backend (e.g., Elasticsearch).
475    pub fn set_search_offloaded(&mut self, offloaded: bool) {
476        self.config.search_offloaded = offloaded;
477    }
478}
479
480/// Connection wrapper for SQLite.
481#[allow(dead_code)]
482pub struct SqliteConnection(pub(crate) PooledConnection<SqliteConnectionManager>);
483
484impl Debug for SqliteConnection {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("SqliteConnection").finish()
487    }
488}
489
490#[async_trait]
491impl Backend for SqliteBackend {
492    type Connection = SqliteConnection;
493
494    fn kind(&self) -> BackendKind {
495        BackendKind::Sqlite
496    }
497
498    fn name(&self) -> &'static str {
499        "sqlite"
500    }
501
502    fn supports(&self, capability: BackendCapability) -> bool {
503        matches!(
504            capability,
505            BackendCapability::Crud
506                | BackendCapability::Versioning
507                | BackendCapability::InstanceHistory
508                | BackendCapability::TypeHistory
509                | BackendCapability::SystemHistory
510                | BackendCapability::BasicSearch
511                | BackendCapability::DateSearch
512                | BackendCapability::ReferenceSearch
513                | BackendCapability::Sorting
514                | BackendCapability::OffsetPagination
515                | BackendCapability::Transactions
516                | BackendCapability::OptimisticLocking
517                | BackendCapability::BulkExport
518                | BackendCapability::BulkSubmitIngest
519                | BackendCapability::BulkSubmitRestWorker
520                | BackendCapability::Include
521                | BackendCapability::Revinclude
522                | BackendCapability::SharedSchema
523        )
524    }
525
526    fn capabilities(&self) -> Vec<BackendCapability> {
527        vec![
528            BackendCapability::Crud,
529            BackendCapability::Versioning,
530            BackendCapability::InstanceHistory,
531            BackendCapability::TypeHistory,
532            BackendCapability::SystemHistory,
533            BackendCapability::BasicSearch,
534            BackendCapability::DateSearch,
535            BackendCapability::ReferenceSearch,
536            BackendCapability::Sorting,
537            BackendCapability::OffsetPagination,
538            BackendCapability::Transactions,
539            BackendCapability::OptimisticLocking,
540            BackendCapability::BulkExport,
541            BackendCapability::BulkSubmitIngest,
542            BackendCapability::BulkSubmitRestWorker,
543            BackendCapability::Include,
544            BackendCapability::Revinclude,
545            BackendCapability::SharedSchema,
546        ]
547    }
548
549    async fn acquire(&self) -> Result<Self::Connection, BackendError> {
550        let conn = self
551            .pool
552            .get()
553            .map_err(|e| BackendError::ConnectionFailed {
554                backend_name: "sqlite".to_string(),
555                message: e.to_string(),
556            })?;
557        Ok(SqliteConnection(conn))
558    }
559
560    async fn release(&self, _conn: Self::Connection) {
561        // Connection is automatically returned to pool when dropped
562    }
563
564    async fn health_check(&self) -> Result<(), BackendError> {
565        let conn = self
566            .get_connection()
567            .map_err(|_| BackendError::Unavailable {
568                backend_name: "sqlite".to_string(),
569                message: "Failed to get connection".to_string(),
570            })?;
571        conn.query_row("SELECT 1", [], |_| Ok(()))
572            .map_err(|e| BackendError::Internal {
573                backend_name: "sqlite".to_string(),
574                message: format!("Health check failed: {}", e),
575                source: None,
576            })?;
577        Ok(())
578    }
579
580    async fn initialize(&self) -> Result<(), BackendError> {
581        self.init_schema().map_err(|e| BackendError::Internal {
582            backend_name: "sqlite".to_string(),
583            message: format!("Failed to initialize schema: {}", e),
584            source: None,
585        })
586    }
587
588    async fn migrate(&self) -> Result<(), BackendError> {
589        // Schema migrations are handled by initialize_schema
590        self.init_schema().map_err(|e| BackendError::Internal {
591            backend_name: "sqlite".to_string(),
592            message: format!("Failed to run migrations: {}", e),
593            source: None,
594        })
595    }
596}
597
598// ============================================================================
599// SearchCapabilityProvider Implementation
600// ============================================================================
601
602use crate::core::capabilities::{
603    GlobalSearchCapabilities, ResourceSearchCapabilities, SearchCapabilityProvider,
604};
605use crate::types::{
606    IncludeCapability, PaginationCapability, ResultModeCapability, SearchParamFullCapability,
607    SearchParamType, SpecialSearchParam,
608};
609
610impl SearchCapabilityProvider for SqliteBackend {
611    fn resource_search_capabilities(
612        &self,
613        resource_type: &str,
614    ) -> Option<ResourceSearchCapabilities> {
615        // Get active parameters for this resource type from the registry
616        let params = {
617            let registry = self.search_registry.read();
618            registry.get_active_params(resource_type)
619        };
620
621        if params.is_empty() {
622            // Also check if there are Resource-level params
623            let common_params = {
624                let registry = self.search_registry.read();
625                registry.get_active_params("Resource")
626            };
627            if common_params.is_empty() {
628                return None;
629            }
630        }
631
632        // Build search parameter capabilities from the registry
633        let mut search_params = Vec::new();
634        for param in &params {
635            let mut cap = SearchParamFullCapability::new(&param.code, param.param_type)
636                .with_definition(&param.url);
637
638            // Add modifiers based on parameter type
639            let modifiers = Self::modifiers_for_type(param.param_type);
640            cap = cap.with_modifiers(modifiers);
641
642            // Add target types for reference parameters
643            if let Some(ref targets) = param.target {
644                cap = cap.with_targets(targets.iter().map(|s| s.as_str()));
645            }
646
647            search_params.push(cap);
648        }
649
650        // Add common Resource-level parameters
651        let common_params = {
652            let registry = self.search_registry.read();
653            registry.get_active_params("Resource")
654        };
655        for param in &common_params {
656            if !search_params.iter().any(|p| p.name == param.code) {
657                let mut cap = SearchParamFullCapability::new(&param.code, param.param_type)
658                    .with_definition(&param.url);
659                cap = cap.with_modifiers(Self::modifiers_for_type(param.param_type));
660                search_params.push(cap);
661            }
662        }
663
664        Some(
665            ResourceSearchCapabilities::new(resource_type)
666                .with_special_params(vec![
667                    SpecialSearchParam::Id,
668                    SpecialSearchParam::LastUpdated,
669                    SpecialSearchParam::Tag,
670                    SpecialSearchParam::Profile,
671                    SpecialSearchParam::Security,
672                ])
673                .with_include_capabilities(vec![
674                    IncludeCapability::Include,
675                    IncludeCapability::Revinclude,
676                ])
677                .with_pagination_capabilities(vec![
678                    PaginationCapability::Count,
679                    PaginationCapability::Offset,
680                    PaginationCapability::Cursor,
681                    PaginationCapability::MaxPageSize(1000),
682                    PaginationCapability::DefaultPageSize(20),
683                ])
684                .with_result_mode_capabilities(vec![
685                    ResultModeCapability::Total,
686                    ResultModeCapability::TotalNone,
687                    ResultModeCapability::TotalAccurate,
688                    ResultModeCapability::SummaryCount,
689                ])
690                .with_param_list(search_params),
691        )
692    }
693
694    fn global_search_capabilities(&self) -> GlobalSearchCapabilities {
695        GlobalSearchCapabilities::new()
696            .with_special_params(vec![
697                SpecialSearchParam::Id,
698                SpecialSearchParam::LastUpdated,
699                SpecialSearchParam::Tag,
700                SpecialSearchParam::Profile,
701                SpecialSearchParam::Security,
702            ])
703            .with_pagination(vec![
704                PaginationCapability::Count,
705                PaginationCapability::Offset,
706                PaginationCapability::Cursor,
707                PaginationCapability::MaxPageSize(1000),
708                PaginationCapability::DefaultPageSize(20),
709            ])
710            .with_system_search()
711    }
712}
713
714impl SqliteBackend {
715    /// Returns supported modifiers for a parameter type.
716    pub(super) fn modifiers_for_type(param_type: SearchParamType) -> Vec<&'static str> {
717        match param_type {
718            SearchParamType::String => vec!["exact", "contains", "text", "missing"],
719            // `not-in` is intentionally omitted: the SQLite backend returns 501
720            // for it (negated value-set filtering is unimplemented), so it must
721            // not be advertised. `text-advanced` is implemented by the token
722            // handler and was previously under-advertised.
723            SearchParamType::Token => vec![
724                "not",
725                "text",
726                "in",
727                "of-type",
728                "code-text",
729                "text-advanced",
730                "missing",
731            ],
732            SearchParamType::Reference => vec![
733                "identifier",
734                "contains",
735                "text",
736                "code-text",
737                "below",
738                "above",
739                "missing",
740            ],
741            SearchParamType::Date => vec!["missing"],
742            SearchParamType::Number => vec!["missing"],
743            SearchParamType::Quantity => vec!["missing"],
744            SearchParamType::Uri => vec!["contains", "below", "above", "missing"],
745            SearchParamType::Composite => vec!["missing"],
746            SearchParamType::Special => vec![],
747        }
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    #[test]
756    fn test_in_memory_backend() {
757        let backend = SqliteBackend::in_memory().unwrap();
758        assert!(backend.is_memory());
759        assert_eq!(backend.name(), "sqlite");
760        assert_eq!(backend.kind(), BackendKind::Sqlite);
761    }
762
763    #[test]
764    fn test_backend_initialization() {
765        let backend = SqliteBackend::in_memory().unwrap();
766        backend.init_schema().unwrap();
767        backend.init_schema().unwrap(); // Should be idempotent
768    }
769
770    #[test]
771    fn test_backend_capabilities() {
772        let backend = SqliteBackend::in_memory().unwrap();
773
774        assert!(backend.supports(BackendCapability::Crud));
775        assert!(backend.supports(BackendCapability::BasicSearch));
776        assert!(backend.supports(BackendCapability::Transactions));
777        assert!(backend.supports(BackendCapability::BulkExport));
778        assert!(backend.supports(BackendCapability::BulkSubmitIngest));
779        assert!(backend.supports(BackendCapability::BulkSubmitRestWorker));
780        assert!(!backend.supports(BackendCapability::FullTextSearch));
781    }
782
783    #[tokio::test]
784    async fn test_health_check() {
785        let backend = SqliteBackend::in_memory().unwrap();
786        backend.init_schema().unwrap();
787        assert!(backend.health_check().await.is_ok());
788    }
789
790    #[tokio::test]
791    async fn test_acquire_release() {
792        let backend = SqliteBackend::in_memory().unwrap();
793        let conn = backend.acquire().await.unwrap();
794        backend.release(conn).await;
795    }
796
797    #[test]
798    fn test_search_capability_provider_patient() {
799        let backend = SqliteBackend::in_memory().unwrap();
800
801        // Get capabilities for Patient resource type
802        let caps = backend.resource_search_capabilities("Patient");
803        assert!(caps.is_some(), "Should have capabilities for Patient");
804
805        let caps = caps.unwrap();
806        assert_eq!(caps.resource_type, "Patient");
807
808        // Should have common special parameters
809        assert!(caps.supports_special(SpecialSearchParam::Id));
810        assert!(caps.supports_special(SpecialSearchParam::LastUpdated));
811
812        // Should support includes
813        assert!(caps.supports_include(IncludeCapability::Include));
814        assert!(caps.supports_include(IncludeCapability::Revinclude));
815
816        // Should have search parameters from the registry
817        // The exact set depends on what's loaded from R4 parameters
818        assert!(
819            !caps.search_params.is_empty(),
820            "Should have search parameters"
821        );
822    }
823
824    #[test]
825    fn test_global_search_capabilities() {
826        let backend = SqliteBackend::in_memory().unwrap();
827
828        let global = backend.global_search_capabilities();
829
830        // Should have common special parameters
831        assert!(
832            global
833                .common_special_params
834                .contains(&SpecialSearchParam::Id)
835        );
836        assert!(
837            global
838                .common_special_params
839                .contains(&SpecialSearchParam::LastUpdated)
840        );
841
842        // Should support system search
843        assert!(global.supports_system_search);
844
845        // Should have pagination capabilities
846        assert!(!global.common_pagination_capabilities.is_empty());
847    }
848
849    #[test]
850    fn test_modifiers_for_type() {
851        // String modifiers
852        let string_mods = SqliteBackend::modifiers_for_type(SearchParamType::String);
853        assert!(string_mods.contains(&"exact"));
854        assert!(string_mods.contains(&"contains"));
855        assert!(string_mods.contains(&"text"));
856        assert!(string_mods.contains(&"missing"));
857
858        // Token modifiers
859        let token_mods = SqliteBackend::modifiers_for_type(SearchParamType::Token);
860        assert!(token_mods.contains(&"not"));
861        assert!(token_mods.contains(&"text"));
862        assert!(token_mods.contains(&"of-type"));
863        // `:code` is a non-spec modifier and must not be advertised.
864        assert!(!token_mods.contains(&"code"));
865        assert!(token_mods.contains(&"text-advanced"));
866        // `not-in` returns 501, so it must not be advertised as supported.
867        assert!(!token_mods.contains(&"not-in"));
868
869        // Reference modifiers
870        let ref_mods = SqliteBackend::modifiers_for_type(SearchParamType::Reference);
871        assert!(ref_mods.contains(&"identifier"));
872
873        // URI modifiers
874        let uri_mods = SqliteBackend::modifiers_for_type(SearchParamType::Uri);
875        assert!(uri_mods.contains(&"below"));
876        assert!(uri_mods.contains(&"above"));
877    }
878}