1use 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#[derive(Debug, Clone)]
17pub struct SqlFragment {
18 pub sql: String,
20 pub params: Vec<SqlParam>,
22}
23
24#[derive(Debug, Clone)]
26pub enum SqlParam {
27 String(String),
29 Integer(i64),
31 Float(f64),
33 Null,
35}
36
37impl SqlParam {
38 pub fn string(s: impl Into<String>) -> Self {
40 SqlParam::String(s.into())
41 }
42
43 pub fn integer(i: i64) -> Self {
45 SqlParam::Integer(i)
46 }
47
48 pub fn float(f: f64) -> Self {
50 SqlParam::Float(f)
51 }
52}
53
54impl SqlFragment {
55 pub fn new(sql: impl Into<String>) -> Self {
57 Self {
58 sql: sql.into(),
59 params: Vec::new(),
60 }
61 }
62
63 pub fn with_params(sql: impl Into<String>, params: Vec<SqlParam>) -> Self {
65 Self {
66 sql: sql.into(),
67 params,
68 }
69 }
70
71 pub fn add_param(&mut self, param: SqlParam) -> String {
73 self.params.push(param);
74 format!("?{}", self.params.len())
75 }
76
77 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 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 pub fn is_empty(&self) -> bool {
101 self.sql.is_empty()
102 }
103}
104
105pub struct QueryBuilder {
107 tenant_id: String,
109 resource_type: String,
111 param_offset: usize,
116 skip_base_params: bool,
118}
119
120impl QueryBuilder {
121 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 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 pub fn build(&self, query: &SearchQuery) -> SqlFragment {
154 let mut conditions = Vec::new();
155
156 let mut base = SqlFragment::new(
159 "SELECT DISTINCT resource_id FROM search_index WHERE tenant_id = ?1 AND resource_type = ?2",
160 );
161
162 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 let search_param_offset = if self.skip_base_params {
171 self.param_offset
172 } else {
173 2 };
175
176 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 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 fn build_parameter_condition(
201 &self,
202 param: &SearchParameter,
203 param_offset: usize,
204 ) -> Option<SqlFragment> {
205 if param.name.starts_with('_') {
207 return self.build_special_parameter_condition(param, param_offset);
208 }
209
210 let mut or_conditions = Vec::new();
212 let mut total_params = 0usize;
213
214 for value in ¶m.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 let mut combined = or_conditions.remove(0);
228 for cond in or_conditions {
229 combined = combined.or(cond);
230 }
231
232 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 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 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 self.build_date_conditions_on_resources(¶m.values, param_offset)
279 }
280 "_text" => {
281 self.build_fts_condition(¶m.values, "narrative_text", param_offset)
283 }
284 "_content" => {
285 self.build_fts_condition(¶m.values, "full_content", param_offset)
287 }
288 "_filter" => {
289 self.build_filter_condition(¶m.values, param_offset)
291 }
292 _ => {
293 None
295 }
296 }
297 }
298
299 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 let search_term = Fts5Search::escape_fts_query(&value.value);
313 if search_term.is_empty() {
314 continue;
315 }
316
317 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 let mut combined = conditions.remove(0);
335 for cond in conditions {
336 combined = combined.or(cond);
337 }
338
339 Some(combined)
340 }
341
342 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 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 match FilterParser::parse(&value.value) {
404 Ok(expr) => {
405 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 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 let mut combined = conditions.remove(0);
428 for cond in conditions {
429 combined = combined.and(cond);
430 }
431
432 Some(combined)
433 }
434
435 fn build_value_condition(
437 &self,
438 param: &SearchParameter,
439 value: &SearchValue,
440 param_offset: usize,
441 ) -> Option<SqlFragment> {
442 if let Some(SearchModifier::Missing) = ¶m.modifier {
444 return self.build_missing_condition(param, value);
445 }
446
447 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 if param.components.is_empty() {
467 return None;
469 }
470 CompositeHandler::build_composite_sql(
471 value,
472 ¶m.name,
473 ¶m.components,
474 param_offset,
475 )
476 }
477 SearchParamType::Special => {
478 return None;
480 }
481 };
482
483 if fragment.is_empty() {
484 None
485 } else {
486 Some(fragment)
487 }
488 }
489
490 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 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 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 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 let column = self.sort_column(&s.parameter);
543 format!("{} {}", column, dir)
544 })
545 .collect();
546
547 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 fn sort_column(&self, parameter: &str) -> &'static str {
561 match parameter {
562 "_id" => "id",
563 "_lastUpdated" => "last_updated",
564 _ => "id",
567 }
568 }
569
570 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 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")); }
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 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 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 assert!(fragment.sql.contains("?3"));
732 assert!(fragment.sql.contains("?4"));
733 assert_eq!(fragment.params.len(), 4);
735 }
736
737 #[test]
738 fn test_multiple_reference_values_correct_offsets() {
739 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 assert!(fragment.sql.contains("?3"));
757 assert!(fragment.sql.contains("?4"));
758 assert!(fragment.sql.contains("?5"));
759 assert!(fragment.sql.contains("?6"));
760 assert_eq!(fragment.params.len(), 6);
762 }
763}