Skip to main content

helios_persistence/backends/sqlite/search/
query_builder.rs

1//! SQL Query Builder for FHIR Search.
2//!
3//! Translates FHIR search queries into SQL statements that can be executed
4//! against the SQLite search_index table.
5
6use std::collections::HashSet;
7
8use crate::types::{SearchModifier, SearchParamType, SearchParameter, SearchQuery, SearchValue};
9
10use super::parameter_handlers::{
11    CompositeHandler, DateHandler, NumberHandler, QuantityHandler, ReferenceHandler, StringHandler,
12    TokenHandler, UriHandler,
13};
14
15/// A fragment of SQL with bound parameters.
16#[derive(Debug, Clone)]
17pub struct SqlFragment {
18    /// The SQL clause.
19    pub sql: String,
20    /// Bound parameter values.
21    pub params: Vec<SqlParam>,
22}
23
24/// A bound SQL parameter.
25#[derive(Debug, Clone)]
26pub enum SqlParam {
27    /// String parameter.
28    String(String),
29    /// Integer parameter.
30    Integer(i64),
31    /// Float parameter.
32    Float(f64),
33    /// Null parameter.
34    Null,
35}
36
37impl SqlParam {
38    /// Creates a string parameter.
39    pub fn string(s: impl Into<String>) -> Self {
40        SqlParam::String(s.into())
41    }
42
43    /// Creates an integer parameter.
44    pub fn integer(i: i64) -> Self {
45        SqlParam::Integer(i)
46    }
47
48    /// Creates a float parameter.
49    pub fn float(f: f64) -> Self {
50        SqlParam::Float(f)
51    }
52}
53
54impl SqlFragment {
55    /// Creates a new SQL fragment.
56    pub fn new(sql: impl Into<String>) -> Self {
57        Self {
58            sql: sql.into(),
59            params: Vec::new(),
60        }
61    }
62
63    /// Creates a fragment with parameters.
64    pub fn with_params(sql: impl Into<String>, params: Vec<SqlParam>) -> Self {
65        Self {
66            sql: sql.into(),
67            params,
68        }
69    }
70
71    /// Adds a parameter placeholder and returns the placeholder string.
72    pub fn add_param(&mut self, param: SqlParam) -> String {
73        self.params.push(param);
74        format!("?{}", self.params.len())
75    }
76
77    /// Combines with another fragment using AND.
78    pub fn and(mut self, other: SqlFragment) -> Self {
79        if !self.sql.is_empty() && !other.sql.is_empty() {
80            self.sql = format!("({}) AND ({})", self.sql, other.sql);
81        } else if !other.sql.is_empty() {
82            self.sql = other.sql;
83        }
84        self.params.extend(other.params);
85        self
86    }
87
88    /// Combines with another fragment using OR.
89    pub fn or(mut self, other: SqlFragment) -> Self {
90        if !self.sql.is_empty() && !other.sql.is_empty() {
91            self.sql = format!("({}) OR ({})", self.sql, other.sql);
92        } else if !other.sql.is_empty() {
93            self.sql = other.sql;
94        }
95        self.params.extend(other.params);
96        self
97    }
98
99    /// Returns true if this fragment is empty.
100    pub fn is_empty(&self) -> bool {
101        self.sql.is_empty()
102    }
103}
104
105/// Builds SQL queries from FHIR search parameters.
106pub struct QueryBuilder {
107    /// The tenant ID for the query.
108    tenant_id: String,
109    /// The resource type being searched.
110    resource_type: String,
111    /// Base parameter offset for parameter placeholders.
112    ///
113    /// When the subquery is embedded in an outer query that already uses
114    /// params ?1-?N, set this to N so search params start at ?(N+1).
115    param_offset: usize,
116    /// Whether to skip tenant/resource type params (they're shared with outer query).
117    skip_base_params: bool,
118}
119
120impl QueryBuilder {
121    /// Creates a new query builder.
122    pub fn new(tenant_id: impl Into<String>, resource_type: impl Into<String>) -> Self {
123        Self {
124            tenant_id: tenant_id.into(),
125            resource_type: resource_type.into(),
126            param_offset: 0,
127            skip_base_params: false,
128        }
129    }
130
131    /// Sets the parameter offset for embedded subqueries.
132    ///
133    /// When the generated SQL will be embedded in an outer query that already
134    /// uses params ?1, ?2, etc., set this offset so the subquery's search
135    /// params don't conflict.
136    ///
137    /// The offset should be the total number of params used by the outer query
138    /// BEFORE the subquery. For example:
139    /// - Outer query uses ?1 (tenant) and ?2 (type): offset = 2
140    /// - Outer query uses ?1-?4 for cursor pagination: offset = 4
141    ///
142    /// Note: The subquery still references ?1 and ?2 for tenant/resource type
143    /// since those bind to the same values as the outer query.
144    pub fn with_param_offset(mut self, offset: usize) -> Self {
145        self.param_offset = offset;
146        self.skip_base_params = true;
147        self
148    }
149
150    /// Builds a complete search query.
151    ///
152    /// Returns SQL that selects matching resource IDs from the search_index table.
153    pub fn build(&self, query: &SearchQuery) -> SqlFragment {
154        let mut conditions = Vec::new();
155
156        // Base conditions: tenant and resource type
157        // These always use ?1 and ?2 since they're shared with the outer query
158        let mut base = SqlFragment::new(
159            "SELECT DISTINCT resource_id FROM search_index WHERE tenant_id = ?1 AND resource_type = ?2",
160        );
161
162        // Only include base params if not skipping (i.e., not embedded in outer query)
163        if !self.skip_base_params {
164            base.params.push(SqlParam::string(&self.tenant_id));
165            base.params.push(SqlParam::string(&self.resource_type));
166        }
167
168        // Calculate the starting offset for search params
169        // If embedded, use the provided offset; otherwise, start after base params
170        let search_param_offset = if self.skip_base_params {
171            self.param_offset
172        } else {
173            2 // After ?1 (tenant) and ?2 (resource_type)
174        };
175
176        // Build conditions for each parameter, tracking how many params we've added
177        let mut current_offset = search_param_offset;
178        for param in &query.parameters {
179            if let Some(condition) = self.build_parameter_condition(param, current_offset) {
180                current_offset += condition.params.len();
181                conditions.push(condition);
182            }
183        }
184
185        // Combine all conditions with AND
186        if !conditions.is_empty() {
187            let mut combined = conditions.remove(0);
188            for cond in conditions {
189                combined = combined.and(cond);
190            }
191
192            base.sql = format!("{} AND ({})", base.sql, combined.sql);
193            base.params.extend(combined.params);
194        }
195
196        base
197    }
198
199    /// Builds a condition for a single search parameter.
200    fn build_parameter_condition(
201        &self,
202        param: &SearchParameter,
203        param_offset: usize,
204    ) -> Option<SqlFragment> {
205        // Handle special parameters
206        if param.name.starts_with('_') {
207            return self.build_special_parameter_condition(param, param_offset);
208        }
209
210        // Multiple values are ORed together
211        let mut or_conditions = Vec::new();
212        let mut total_params = 0usize;
213
214        for value in &param.values {
215            let condition = self.build_value_condition(param, value, param_offset + total_params);
216            if let Some(cond) = condition {
217                total_params += cond.params.len();
218                or_conditions.push(cond);
219            }
220        }
221
222        if or_conditions.is_empty() {
223            return None;
224        }
225
226        // Combine with OR
227        let mut combined = or_conditions.remove(0);
228        for cond in or_conditions {
229            combined = combined.or(cond);
230        }
231
232        // Wrap in subquery to ensure proper AND/OR semantics
233        Some(SqlFragment::with_params(
234            format!(
235                "resource_id IN (SELECT resource_id FROM search_index WHERE tenant_id = ?1 AND resource_type = ?2 AND param_name = '{}' AND ({}))",
236                param.name, combined.sql
237            ),
238            combined.params,
239        ))
240    }
241
242    /// Builds a condition for a special parameter (_id, _lastUpdated, etc.).
243    fn build_special_parameter_condition(
244        &self,
245        param: &SearchParameter,
246        param_offset: usize,
247    ) -> Option<SqlFragment> {
248        match param.name.as_str() {
249            "_id" => {
250                // _id searches directly on the resources table
251                let mut conditions = Vec::new();
252                for (i, value) in param.values.iter().enumerate() {
253                    conditions.push(SqlFragment::with_params(
254                        format!("id = ?{}", param_offset + i + 1),
255                        vec![SqlParam::string(&value.value)],
256                    ));
257                }
258
259                if conditions.is_empty() {
260                    return None;
261                }
262
263                let mut combined = conditions.remove(0);
264                for cond in conditions {
265                    combined = combined.or(cond);
266                }
267
268                Some(SqlFragment::with_params(
269                    format!(
270                        "resource_id IN (SELECT id FROM resources WHERE tenant_id = ?1 AND resource_type = ?2 AND ({}))",
271                        combined.sql
272                    ),
273                    combined.params,
274                ))
275            }
276            "_lastUpdated" => {
277                // _lastUpdated is stored in the resources table
278                self.build_date_conditions_on_resources(&param.values, param_offset)
279            }
280            "_text" => {
281                // _text searches the narrative text (text.div) via FTS5
282                self.build_fts_condition(&param.values, "narrative_text", param_offset)
283            }
284            "_content" => {
285                // _content searches all text content via FTS5
286                self.build_fts_condition(&param.values, "full_content", param_offset)
287            }
288            "_filter" => {
289                // _filter uses advanced filter expression syntax
290                self.build_filter_condition(&param.values, param_offset)
291            }
292            _ => {
293                // Other special parameters - fall through to regular handling
294                None
295            }
296        }
297    }
298
299    /// Builds FTS5 conditions for _text and _content searches.
300    fn build_fts_condition(
301        &self,
302        values: &[SearchValue],
303        column: &str,
304        param_offset: usize,
305    ) -> Option<SqlFragment> {
306        use super::fts::Fts5Search;
307
308        let mut conditions = Vec::new();
309
310        for (i, value) in values.iter().enumerate() {
311            // Escape and prepare the search term
312            let search_term = Fts5Search::escape_fts_query(&value.value);
313            if search_term.is_empty() {
314                continue;
315            }
316
317            // Build the FTS match query
318            // Use the column prefix to search only the specified column
319            let param_num = param_offset + i + 1;
320            conditions.push(SqlFragment::with_params(
321                format!(
322                    "resource_id IN (SELECT resource_id FROM resource_fts WHERE {} MATCH ?{})",
323                    column, param_num
324                ),
325                vec![SqlParam::string(&search_term)],
326            ));
327        }
328
329        if conditions.is_empty() {
330            return None;
331        }
332
333        // OR together multiple search terms
334        let mut combined = conditions.remove(0);
335        for cond in conditions {
336            combined = combined.or(cond);
337        }
338
339        Some(combined)
340    }
341
342    /// Builds date conditions for the resources table (for _lastUpdated).
343    fn build_date_conditions_on_resources(
344        &self,
345        values: &[SearchValue],
346        param_offset: usize,
347    ) -> Option<SqlFragment> {
348        let mut conditions = Vec::new();
349
350        for (i, value) in values.iter().enumerate() {
351            let cond = DateHandler::build_sql(value, param_offset + i);
352            if !cond.is_empty() {
353                conditions.push(cond);
354            }
355        }
356
357        if conditions.is_empty() {
358            return None;
359        }
360
361        let mut combined = conditions.remove(0);
362        for cond in conditions {
363            combined = combined.or(cond);
364        }
365
366        Some(SqlFragment::with_params(
367            format!(
368                "resource_id IN (SELECT id FROM resources WHERE tenant_id = ?1 AND resource_type = ?2 AND ({}))",
369                combined.sql.replace("value_date", "last_updated")
370            ),
371            combined.params,
372        ))
373    }
374
375    /// Builds conditions for _filter parameter.
376    ///
377    /// The _filter parameter allows complex filter expressions using a
378    /// syntax similar to FHIRPath. See <https://build.fhir.org/search_filter.html>.
379    ///
380    /// # Examples
381    ///
382    /// ```text
383    /// _filter=name eq "Smith"
384    /// _filter=name eq "Smith" and birthdate gt 1980-01-01
385    /// _filter=(status eq active or status eq pending) and category eq urgent
386    /// ```
387    fn build_filter_condition(
388        &self,
389        values: &[SearchValue],
390        param_offset: usize,
391    ) -> Option<SqlFragment> {
392        use super::filter_parser::{FilterParser, FilterSqlGenerator};
393
394        if values.is_empty() {
395            return None;
396        }
397
398        let mut conditions = Vec::new();
399        let mut current_offset = param_offset;
400
401        for value in values {
402            // Parse the filter expression
403            match FilterParser::parse(&value.value) {
404                Ok(expr) => {
405                    // Generate SQL from the parsed expression
406                    let mut generator = FilterSqlGenerator::new(current_offset);
407                    let sql = generator.generate(&expr);
408                    current_offset += sql.params.len();
409                    conditions.push(sql);
410                }
411                Err(e) => {
412                    // Log parse error but continue with other filters
413                    tracing::warn!(
414                        "Failed to parse _filter expression '{}': {}",
415                        value.value,
416                        e
417                    );
418                }
419            }
420        }
421
422        if conditions.is_empty() {
423            return None;
424        }
425
426        // AND together multiple _filter values
427        let mut combined = conditions.remove(0);
428        for cond in conditions {
429            combined = combined.and(cond);
430        }
431
432        Some(combined)
433    }
434
435    /// Builds a condition for a single value.
436    fn build_value_condition(
437        &self,
438        param: &SearchParameter,
439        value: &SearchValue,
440        param_offset: usize,
441    ) -> Option<SqlFragment> {
442        // Handle :missing modifier
443        if let Some(SearchModifier::Missing) = &param.modifier {
444            return self.build_missing_condition(param, value);
445        }
446
447        // Build condition based on parameter type
448        let fragment = match param.param_type {
449            SearchParamType::String => {
450                StringHandler::build_sql(value, param.modifier.as_ref(), param_offset)
451            }
452            SearchParamType::Token => {
453                TokenHandler::build_sql(value, param.modifier.as_ref(), param_offset)
454            }
455            SearchParamType::Date => DateHandler::build_sql(value, param_offset),
456            SearchParamType::Number => NumberHandler::build_sql(value, param_offset),
457            SearchParamType::Quantity => QuantityHandler::build_sql(value, param_offset),
458            SearchParamType::Reference => {
459                ReferenceHandler::build_sql(value, param.modifier.as_ref(), param_offset)
460            }
461            SearchParamType::Uri => {
462                UriHandler::build_sql(value, param.modifier.as_ref(), param_offset)
463            }
464            SearchParamType::Composite => {
465                // Composite parameters require component definitions
466                if param.components.is_empty() {
467                    // No components defined, cannot process
468                    return None;
469                }
470                CompositeHandler::build_composite_sql(
471                    value,
472                    &param.name,
473                    &param.components,
474                    param_offset,
475                )
476            }
477            SearchParamType::Special => {
478                // Should have been handled by build_special_parameter_condition
479                return None;
480            }
481        };
482
483        if fragment.is_empty() {
484            None
485        } else {
486            Some(fragment)
487        }
488    }
489
490    /// Builds a condition for the :missing modifier.
491    fn build_missing_condition(
492        &self,
493        param: &SearchParameter,
494        value: &SearchValue,
495    ) -> Option<SqlFragment> {
496        let is_missing = value.value.to_lowercase() == "true";
497
498        if is_missing {
499            // Missing = true: resources with NO index entry for this param
500            Some(SqlFragment::new(format!(
501                "resource_id NOT IN (SELECT resource_id FROM search_index WHERE tenant_id = ?1 AND resource_type = ?2 AND param_name = '{}')",
502                param.name
503            )))
504        } else {
505            // Missing = false: resources WITH an index entry for this param
506            Some(SqlFragment::new(format!(
507                "resource_id IN (SELECT resource_id FROM search_index WHERE tenant_id = ?1 AND resource_type = ?2 AND param_name = '{}')",
508                param.name
509            )))
510        }
511    }
512
513    /// Builds an ORDER BY clause.
514    ///
515    /// Supports multiple sort directives (e.g., `_sort=name,-birthdate`).
516    /// Each directive is processed in order, with a tie-breaker (`id ASC`) added
517    /// at the end for stable pagination.
518    ///
519    /// # Supported Sort Parameters
520    ///
521    /// - `_id`: Sorts by resource logical ID
522    /// - `_lastUpdated`: Sorts by last modification timestamp
523    ///
524    /// Other sort parameters are currently mapped to resource ID as a fallback.
525    /// Full support for arbitrary search parameters would require additional
526    /// SQL joins with the search_index table.
527    pub fn build_order_by(&self, query: &SearchQuery) -> String {
528        if query.sort.is_empty() {
529            return "ORDER BY last_updated DESC, id ASC".to_string();
530        }
531
532        let mut clauses: Vec<String> = query
533            .sort
534            .iter()
535            .map(|s| {
536                let dir = match s.direction {
537                    crate::types::SortDirection::Ascending => "ASC",
538                    crate::types::SortDirection::Descending => "DESC",
539                };
540
541                // Map sort parameters to SQL columns
542                let column = self.sort_column(&s.parameter);
543                format!("{} {}", column, dir)
544            })
545            .collect();
546
547        // Add tie-breaker for stable pagination if not already sorting by id
548        let sorts_by_id = query.sort.iter().any(|s| s.parameter == "_id");
549        if !sorts_by_id {
550            clauses.push("id ASC".to_string());
551        }
552
553        format!("ORDER BY {}", clauses.join(", "))
554    }
555
556    /// Maps a sort parameter name to the corresponding SQL column.
557    ///
558    /// This is used by `build_order_by` to translate FHIR sort parameters
559    /// to SQLite column names.
560    fn sort_column(&self, parameter: &str) -> &'static str {
561        match parameter {
562            "_id" => "id",
563            "_lastUpdated" => "last_updated",
564            // Future: could support arbitrary parameters via search_index join
565            // For now, use id as a stable fallback
566            _ => "id",
567        }
568    }
569
570    /// Builds a LIMIT clause.
571    pub fn build_limit(&self, query: &SearchQuery) -> String {
572        let count = query.count.unwrap_or(100);
573        if let Some(offset) = query.offset {
574            format!("LIMIT {} OFFSET {}", count + 1, offset)
575        } else {
576            format!("LIMIT {}", count + 1)
577        }
578    }
579
580    /// Returns the set of parameter names used in a query.
581    pub fn get_used_params(query: &SearchQuery) -> HashSet<String> {
582        let mut params = HashSet::new();
583        for param in &query.parameters {
584            params.insert(param.name.clone());
585        }
586        params
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_sql_fragment() {
596        let mut frag = SqlFragment::new("value_string = ?1");
597        frag.params.push(SqlParam::string("test"));
598
599        assert!(!frag.is_empty());
600        assert_eq!(frag.params.len(), 1);
601    }
602
603    #[test]
604    fn test_fragment_and() {
605        let frag1 = SqlFragment::with_params("a = ?1", vec![SqlParam::string("x")]);
606        let frag2 = SqlFragment::with_params("b = ?2", vec![SqlParam::string("y")]);
607
608        let combined = frag1.and(frag2);
609        assert!(combined.sql.contains("AND"));
610        assert_eq!(combined.params.len(), 2);
611    }
612
613    #[test]
614    fn test_fragment_or() {
615        let frag1 = SqlFragment::with_params("a = ?1", vec![SqlParam::string("x")]);
616        let frag2 = SqlFragment::with_params("b = ?2", vec![SqlParam::string("y")]);
617
618        let combined = frag1.or(frag2);
619        assert!(combined.sql.contains("OR"));
620    }
621
622    #[test]
623    fn test_query_builder_basic() {
624        let builder = QueryBuilder::new("tenant1", "Patient");
625
626        let query = SearchQuery::new("Patient");
627        let fragment = builder.build(&query);
628
629        assert!(fragment.sql.contains("search_index"));
630        assert!(fragment.sql.contains("tenant_id"));
631        assert!(fragment.sql.contains("resource_type"));
632    }
633
634    #[test]
635    fn test_query_builder_with_param() {
636        let builder = QueryBuilder::new("tenant1", "Patient");
637
638        let mut query = SearchQuery::new("Patient");
639        query.parameters.push(SearchParameter {
640            name: "name".to_string(),
641            param_type: SearchParamType::String,
642            modifier: None,
643            values: vec![SearchValue::eq("smith")],
644            chain: vec![],
645            components: vec![],
646        });
647
648        let fragment = builder.build(&query);
649
650        assert!(fragment.sql.contains("param_name = 'name'"));
651    }
652
653    #[test]
654    fn test_order_by_default() {
655        let builder = QueryBuilder::new("tenant1", "Patient");
656        let query = SearchQuery::new("Patient");
657
658        let order_by = builder.build_order_by(&query);
659        assert!(order_by.contains("last_updated DESC"));
660        assert!(order_by.contains("id ASC")); // Tie-breaker for stable pagination
661    }
662
663    #[test]
664    fn test_order_by_multiple_fields() {
665        use crate::types::{SortDirection, SortDirective};
666
667        let builder = QueryBuilder::new("tenant1", "Patient");
668        let mut query = SearchQuery::new("Patient");
669        query.sort = vec![
670            SortDirective {
671                parameter: "_lastUpdated".to_string(),
672                direction: SortDirection::Descending,
673            },
674            SortDirective {
675                parameter: "_id".to_string(),
676                direction: SortDirection::Ascending,
677            },
678        ];
679
680        let order_by = builder.build_order_by(&query);
681        assert_eq!(order_by, "ORDER BY last_updated DESC, id ASC");
682    }
683
684    #[test]
685    fn test_order_by_adds_tiebreaker() {
686        use crate::types::{SortDirection, SortDirective};
687
688        let builder = QueryBuilder::new("tenant1", "Patient");
689        let mut query = SearchQuery::new("Patient");
690        query.sort = vec![SortDirective {
691            parameter: "_lastUpdated".to_string(),
692            direction: SortDirection::Ascending,
693        }];
694
695        let order_by = builder.build_order_by(&query);
696        // Should have id ASC as tie-breaker since _id is not in sort list
697        assert_eq!(order_by, "ORDER BY last_updated ASC, id ASC");
698    }
699
700    #[test]
701    fn test_limit_with_offset() {
702        let builder = QueryBuilder::new("tenant1", "Patient");
703        let mut query = SearchQuery::new("Patient");
704        query.count = Some(10);
705        query.offset = Some(20);
706
707        let limit = builder.build_limit(&query);
708        assert!(limit.contains("LIMIT 11"));
709        assert!(limit.contains("OFFSET 20"));
710    }
711
712    #[test]
713    fn test_reference_search_id_only() {
714        // Test that ID-only reference search generates correct param numbers
715        let builder = QueryBuilder::new("default", "Immunization");
716
717        let mut query = SearchQuery::new("Immunization");
718        query.parameters.push(SearchParameter {
719            name: "patient".to_string(),
720            param_type: SearchParamType::Reference,
721            modifier: None,
722            values: vec![SearchValue::eq("us-core-client-tests-patient")],
723            chain: vec![],
724            components: vec![],
725        });
726
727        let fragment = builder.build(&query);
728
729        // Should use ?3 and ?4 for the two params in ID-only reference search
730        // (after ?1 tenant and ?2 resource_type)
731        assert!(fragment.sql.contains("?3"));
732        assert!(fragment.sql.contains("?4"));
733        // Should have 4 total params: tenant, resource_type, ref_value, ref_value
734        assert_eq!(fragment.params.len(), 4);
735    }
736
737    #[test]
738    fn test_multiple_reference_values_correct_offsets() {
739        // Test that multiple values get correct param offsets
740        let builder = QueryBuilder::new("default", "Immunization");
741
742        let mut query = SearchQuery::new("Immunization");
743        query.parameters.push(SearchParameter {
744            name: "patient".to_string(),
745            param_type: SearchParamType::Reference,
746            modifier: None,
747            values: vec![SearchValue::eq("patient-1"), SearchValue::eq("patient-2")],
748            chain: vec![],
749            components: vec![],
750        });
751
752        let fragment = builder.build(&query);
753
754        // First value uses ?3 and ?4 (2 params for ID-only)
755        // Second value uses ?5 and ?6 (2 more params for ID-only)
756        assert!(fragment.sql.contains("?3"));
757        assert!(fragment.sql.contains("?4"));
758        assert!(fragment.sql.contains("?5"));
759        assert!(fragment.sql.contains("?6"));
760        // Should have 6 total params: tenant, resource_type, + 4 for 2 ID-only refs
761        assert_eq!(fragment.params.len(), 6);
762    }
763}