1#![allow(missing_docs)]
12
13use std::sync::Arc;
14
15use parking_lot::RwLock;
16
17use crate::error::{BackendError, StorageResult};
18use crate::search::SearchParameterRegistry;
19use crate::types::{ChainConfig, ReverseChainedParameter, SearchParamType, SearchValue};
20
21use super::query_builder::{SqlFragment, SqlParam};
22
23#[derive(Debug, Clone)]
25pub struct ChainLink {
26 pub reference_param: String,
28 pub target_type: String,
30}
31
32#[derive(Debug, Clone)]
34pub struct ParsedChain {
35 pub links: Vec<ChainLink>,
37 pub terminal_param: String,
39 pub terminal_type: SearchParamType,
41}
42
43#[derive(Debug, Clone)]
45pub enum ChainError {
46 MaxDepthExceeded { depth: usize, max: usize },
48 UnknownReferenceParam {
50 resource_type: String,
51 param: String,
52 },
53 AmbiguousTargetType {
55 resource_type: String,
56 param: String,
57 },
58 UnknownTerminalParam {
60 resource_type: String,
61 param: String,
62 },
63 EmptyChain,
65 InvalidSyntax { message: String },
67}
68
69impl std::fmt::Display for ChainError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 ChainError::MaxDepthExceeded { depth, max } => {
73 write!(
74 f,
75 "Chain depth {} exceeds maximum allowed depth {}",
76 depth, max
77 )
78 }
79 ChainError::UnknownReferenceParam {
80 resource_type,
81 param,
82 } => {
83 write!(
84 f,
85 "Unknown reference parameter '{}' for resource type '{}'",
86 param, resource_type
87 )
88 }
89 ChainError::AmbiguousTargetType {
90 resource_type,
91 param,
92 } => {
93 write!(
94 f,
95 "Ambiguous target type for parameter '{}' on '{}'. Use type modifier.",
96 param, resource_type
97 )
98 }
99 ChainError::UnknownTerminalParam {
100 resource_type,
101 param,
102 } => {
103 write!(
104 f,
105 "Unknown terminal parameter '{}' for resource type '{}'",
106 param, resource_type
107 )
108 }
109 ChainError::EmptyChain => write!(f, "Empty chain"),
110 ChainError::InvalidSyntax { message } => write!(f, "Invalid chain syntax: {}", message),
111 }
112 }
113}
114
115impl From<ChainError> for BackendError {
116 fn from(e: ChainError) -> Self {
117 BackendError::Internal {
118 backend_name: "sqlite".to_string(),
119 message: e.to_string(),
120 source: None,
121 }
122 }
123}
124
125pub struct ChainQueryBuilder {
130 tenant_id: String,
132 base_type: String,
134 registry: Arc<RwLock<SearchParameterRegistry>>,
136 config: ChainConfig,
138 param_offset: usize,
140}
141
142impl ChainQueryBuilder {
143 pub fn new(
145 tenant_id: impl Into<String>,
146 base_type: impl Into<String>,
147 registry: Arc<RwLock<SearchParameterRegistry>>,
148 ) -> Self {
149 Self {
150 tenant_id: tenant_id.into(),
151 base_type: base_type.into(),
152 registry,
153 config: ChainConfig::default(),
154 param_offset: 2, }
156 }
157
158 pub fn with_config(mut self, config: ChainConfig) -> Self {
160 self.config = config;
161 self
162 }
163
164 pub fn with_param_offset(mut self, offset: usize) -> Self {
166 self.param_offset = offset;
167 self
168 }
169
170 pub fn parse_chain(&self, chain_str: &str) -> Result<ParsedChain, ChainError> {
180 if chain_str.is_empty() {
181 return Err(ChainError::EmptyChain);
182 }
183
184 let parts: Vec<&str> = chain_str.split('.').collect();
185 if parts.len() < 2 {
186 return Err(ChainError::InvalidSyntax {
187 message: "Chain must have at least two parts (reference.param)".to_string(),
188 });
189 }
190
191 let chain_depth = parts.len() - 1; if !self.config.validate_forward_depth(chain_depth) {
194 return Err(ChainError::MaxDepthExceeded {
195 depth: chain_depth,
196 max: self.config.max_forward_depth,
197 });
198 }
199
200 let mut links = Vec::new();
201 let mut current_type = self.base_type.clone();
202
203 for part in parts.iter().take(parts.len() - 1) {
205 let (ref_param, explicit_type) = self.parse_chain_part(part);
206
207 let target_type = self.resolve_target_type(¤t_type, &ref_param, explicit_type)?;
209
210 links.push(ChainLink {
211 reference_param: ref_param,
212 target_type: target_type.clone(),
213 });
214
215 current_type = target_type;
216 }
217
218 let terminal_param = parts[parts.len() - 1].to_string();
220 let terminal_type = self.resolve_terminal_type(¤t_type, &terminal_param)?;
221
222 Ok(ParsedChain {
223 links,
224 terminal_param,
225 terminal_type,
226 })
227 }
228
229 fn parse_chain_part(&self, part: &str) -> (String, Option<String>) {
233 if let Some((param, type_mod)) = part.split_once(':') {
234 (param.to_string(), Some(type_mod.to_string()))
235 } else {
236 (part.to_string(), None)
237 }
238 }
239
240 fn resolve_target_type(
246 &self,
247 resource_type: &str,
248 ref_param: &str,
249 explicit_type: Option<String>,
250 ) -> Result<String, ChainError> {
251 if let Some(t) = explicit_type {
253 return Ok(t);
254 }
255
256 let registry = self.registry.read();
258 if let Some(param_def) = registry.get_param(resource_type, ref_param) {
259 if param_def.param_type != SearchParamType::Reference {
261 return Err(ChainError::UnknownReferenceParam {
262 resource_type: resource_type.to_string(),
263 param: ref_param.to_string(),
264 });
265 }
266
267 if let Some(ref targets) = param_def.target {
269 if targets.len() == 1 {
270 return Ok(targets[0].clone());
271 } else if targets.is_empty() {
272 return Ok(self.infer_target_type(ref_param));
274 } else {
275 return Ok(self.infer_target_type(ref_param));
279 }
280 }
281 }
282
283 Ok(self.infer_target_type(ref_param))
285 }
286
287 fn infer_target_type(&self, ref_param: &str) -> String {
289 match ref_param {
290 "patient" | "subject" => "Patient".to_string(),
291 "practitioner" | "performer" | "requester" | "author" => "Practitioner".to_string(),
292 "organization" | "managingOrganization" | "custodian" => "Organization".to_string(),
293 "encounter" | "context" => "Encounter".to_string(),
294 "location" => "Location".to_string(),
295 "device" => "Device".to_string(),
296 "specimen" => "Specimen".to_string(),
297 "medication" => "Medication".to_string(),
298 "condition" => "Condition".to_string(),
299 _ => {
300 let mut chars = ref_param.chars();
302 match chars.next() {
303 Some(c) => c.to_uppercase().chain(chars).collect(),
304 None => ref_param.to_string(),
305 }
306 }
307 }
308 }
309
310 fn resolve_terminal_type(
312 &self,
313 resource_type: &str,
314 param_name: &str,
315 ) -> Result<SearchParamType, ChainError> {
316 let registry = self.registry.read();
317 if let Some(param_def) = registry.get_param(resource_type, param_name) {
318 Ok(param_def.param_type)
319 } else {
320 match param_name {
322 "_id" | "id" => Ok(SearchParamType::Token),
323 "name" | "family" | "given" | "text" | "display" => Ok(SearchParamType::String),
324 "identifier" | "code" | "status" | "type" | "category" => {
325 Ok(SearchParamType::Token)
326 }
327 _ => Err(ChainError::UnknownTerminalParam {
328 resource_type: resource_type.to_string(),
329 param: param_name.to_string(),
330 }),
331 }
332 }
333 }
334
335 pub fn build_forward_chain_sql(
362 &self,
363 chain: &ParsedChain,
364 value: &SearchValue,
365 ) -> StorageResult<SqlFragment> {
366 if chain.links.is_empty() {
367 return Err(BackendError::Internal {
368 backend_name: "sqlite".to_string(),
369 message: "Empty chain".to_string(),
370 source: None,
371 }
372 .into());
373 }
374
375 let param_num = self.param_offset + 1;
377
378 let (terminal_sql, terminal_param) =
380 self.build_terminal_condition(chain, value, param_num)?;
381
382 let terminal_type = &chain.links[chain.links.len() - 1].target_type;
384
385 let mut current_sql = format!(
387 "SELECT '{}/{}' || si{}.resource_id FROM search_index si{} \
388 WHERE si{}.tenant_id = ?1 AND si{}.resource_type = '{}' \
389 AND si{}.param_name = '{}' AND {}",
390 terminal_type,
391 "", chain.links.len(),
393 chain.links.len(),
394 chain.links.len(),
395 chain.links.len(),
396 terminal_type,
397 chain.links.len(),
398 chain.terminal_param,
399 terminal_sql
400 );
401
402 for (i, link) in chain.links.iter().enumerate().rev() {
404 let link_num = i + 1;
405 let current_type = if i == 0 {
407 &self.base_type
408 } else {
409 &chain.links[i - 1].target_type
410 };
411
412 if i == 0 {
413 current_sql = format!(
415 "SELECT si{link_num}.resource_id FROM search_index si{link_num} \
416 WHERE si{link_num}.tenant_id = ?1 AND si{link_num}.resource_type = '{current_type}' \
417 AND si{link_num}.param_name = '{ref_param}' \
418 AND si{link_num}.value_reference IN ({inner})",
419 link_num = link_num,
420 current_type = current_type,
421 ref_param = link.reference_param,
422 inner = current_sql
423 );
424 } else {
425 current_sql = format!(
427 "SELECT '{current_type}/' || si{link_num}.resource_id FROM search_index si{link_num} \
428 WHERE si{link_num}.tenant_id = ?1 AND si{link_num}.resource_type = '{current_type}' \
429 AND si{link_num}.param_name = '{ref_param}' \
430 AND si{link_num}.value_reference IN ({inner})",
431 current_type = current_type,
432 link_num = link_num,
433 ref_param = link.reference_param,
434 inner = current_sql
435 );
436 }
437 }
438
439 let final_sql = format!("r.id IN ({})", current_sql);
441
442 Ok(SqlFragment::with_params(final_sql, vec![terminal_param]))
443 }
444
445 fn build_terminal_condition(
447 &self,
448 chain: &ParsedChain,
449 value: &SearchValue,
450 param_num: usize,
451 ) -> StorageResult<(String, SqlParam)> {
452 let alias_num = chain.links.len();
453 let alias = format!("si{}", alias_num);
454
455 let (condition, param) = match chain.terminal_type {
456 SearchParamType::String => {
457 let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
458 (
459 format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
460 SqlParam::String(format!("%{}%", escaped)),
461 )
462 }
463 SearchParamType::Token => {
464 if let Some((system, code)) = value.value.split_once('|') {
466 if system.is_empty() {
467 (
468 format!(
469 "({}.value_token_system IS NULL OR {}.value_token_system = '') \
470 AND {}.value_token_code = ?{}",
471 alias, alias, alias, param_num
472 ),
473 SqlParam::String(code.to_string()),
474 )
475 } else {
476 (
477 format!(
478 "{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
479 alias,
480 system.replace('\'', "''"),
481 alias,
482 param_num
483 ),
484 SqlParam::String(code.to_string()),
485 )
486 }
487 } else {
488 (
489 format!("{}.value_token_code = ?{}", alias, param_num),
490 SqlParam::String(value.value.clone()),
491 )
492 }
493 }
494 SearchParamType::Reference => (
495 format!("{}.value_reference LIKE ?{}", alias, param_num),
496 SqlParam::String(format!("%{}%", value.value)),
497 ),
498 SearchParamType::Date => {
499 let date_col = format!("{}.value_date", alias);
501 build_date_condition(&date_col, value, param_num)
502 }
503 SearchParamType::Number => {
504 let num_col = format!("{}.value_number", alias);
505 build_number_condition(&num_col, value, param_num)
506 }
507 SearchParamType::Quantity => {
508 let qty_col = format!("{}.value_quantity_value", alias);
510 build_number_condition(&qty_col, value, param_num)
511 }
512 SearchParamType::Uri => (
513 format!("{}.value_uri = ?{}", alias, param_num),
514 SqlParam::String(value.value.clone()),
515 ),
516 _ => (
517 format!("{}.value_string LIKE ?{}", alias, param_num),
518 SqlParam::String(format!("%{}%", value.value)),
519 ),
520 };
521
522 Ok((condition, param))
523 }
524
525 pub fn build_reverse_chain_sql(
549 &self,
550 reverse_chain: &ReverseChainedParameter,
551 ) -> StorageResult<SqlFragment> {
552 let depth = reverse_chain.depth();
554 if !self.config.validate_reverse_depth(depth) {
555 return Err(BackendError::Internal {
556 backend_name: "sqlite".to_string(),
557 message: format!(
558 "Reverse chain depth {} exceeds maximum {}",
559 depth, self.config.max_reverse_depth
560 ),
561 source: None,
562 }
563 .into());
564 }
565
566 let param_num = self.param_offset + 1;
567 let (sql, params) = self.build_reverse_chain_recursive(reverse_chain, 1, param_num)?;
568
569 Ok(SqlFragment::with_params(
570 format!("r.id IN ({})", sql),
571 params,
572 ))
573 }
574
575 fn build_reverse_chain_recursive(
577 &self,
578 rc: &ReverseChainedParameter,
579 depth: usize,
580 param_num: usize,
581 ) -> StorageResult<(String, Vec<SqlParam>)> {
582 let alias = format!("si{}", depth);
583
584 if rc.is_terminal() {
585 let value = rc.value.as_ref().ok_or_else(|| BackendError::Internal {
587 backend_name: "sqlite".to_string(),
588 message: "Terminal reverse chain must have a value".to_string(),
589 source: None,
590 })?;
591
592 let (search_condition, search_param) = self.build_reverse_terminal_condition(
594 &rc.source_type,
595 &rc.search_param,
596 value,
597 depth + 1,
598 param_num,
599 )?;
600
601 let depth2 = depth + 1;
603 let sql = format!(
604 "SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
605 FROM search_index {alias} \
606 WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{src_type}' \
607 AND {alias}.param_name = '{ref_param}' \
608 AND {alias}.value_reference LIKE '{base_type}/%' \
609 AND {alias}.resource_id IN (\
610 SELECT si{depth2}.resource_id FROM search_index si{depth2} \
611 WHERE si{depth2}.tenant_id = ?1 AND si{depth2}.resource_type = '{src_type}' \
612 AND si{depth2}.param_name = '{search_param_name}' AND {search_condition}\
613 )",
614 alias = alias,
615 src_type = rc.source_type,
616 ref_param = rc.reference_param,
617 base_type = self.base_type,
618 depth2 = depth2,
619 search_param_name = rc.search_param,
620 search_condition = search_condition,
621 );
622
623 Ok((sql, vec![search_param]))
624 } else {
625 let inner = rc.nested.as_ref().ok_or_else(|| BackendError::Internal {
627 backend_name: "sqlite".to_string(),
628 message: "Non-terminal reverse chain must have nested chain".to_string(),
629 source: None,
630 })?;
631
632 let inner_builder = ChainQueryBuilder::new(
634 &self.tenant_id,
635 &rc.source_type,
636 Arc::clone(&self.registry),
637 )
638 .with_config(self.config.clone())
639 .with_param_offset(param_num - 1);
640
641 let (inner_sql, inner_params) =
642 inner_builder.build_reverse_chain_recursive(inner, depth + 1, param_num)?;
643
644 let sql = format!(
646 "SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
647 FROM search_index {alias} \
648 WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{}' \
649 AND {alias}.param_name = '{}' \
650 AND {alias}.value_reference LIKE '{}/%' \
651 AND {alias}.resource_id IN ({inner_sql})",
652 rc.source_type,
653 rc.reference_param,
654 self.base_type,
655 alias = alias,
656 );
657
658 Ok((sql, inner_params))
659 }
660 }
661
662 fn build_reverse_terminal_condition(
664 &self,
665 resource_type: &str,
666 param_name: &str,
667 value: &SearchValue,
668 depth: usize,
669 param_num: usize,
670 ) -> StorageResult<(String, SqlParam)> {
671 let param_type = {
673 let registry = self.registry.read();
674 registry
675 .get_param(resource_type, param_name)
676 .map(|p| p.param_type)
677 .unwrap_or_else(|| self.infer_param_type(param_name))
678 };
679
680 let alias = format!("si{}", depth);
681
682 let (condition, param) = match param_type {
683 SearchParamType::String => {
684 let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
685 (
686 format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
687 SqlParam::String(format!("%{}%", escaped)),
688 )
689 }
690 SearchParamType::Token => {
691 if let Some((system, code)) = value.value.split_once('|') {
692 if system.is_empty() {
693 (
694 format!(
695 "({}.value_token_system IS NULL OR {}.value_token_system = '') \
696 AND {}.value_token_code = ?{}",
697 alias, alias, alias, param_num
698 ),
699 SqlParam::String(code.to_string()),
700 )
701 } else {
702 (
703 format!(
704 "{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
705 alias,
706 system.replace('\'', "''"),
707 alias,
708 param_num
709 ),
710 SqlParam::String(code.to_string()),
711 )
712 }
713 } else {
714 (
715 format!("{}.value_token_code = ?{}", alias, param_num),
716 SqlParam::String(value.value.clone()),
717 )
718 }
719 }
720 SearchParamType::Reference => (
721 format!("{}.value_reference LIKE ?{}", alias, param_num),
722 SqlParam::String(format!("%{}%", value.value)),
723 ),
724 SearchParamType::Date => {
725 let date_col = format!("{}.value_date", alias);
726 build_date_condition(&date_col, value, param_num)
727 }
728 SearchParamType::Number => {
729 let num_col = format!("{}.value_number", alias);
730 build_number_condition(&num_col, value, param_num)
731 }
732 SearchParamType::Quantity => {
733 let qty_col = format!("{}.value_quantity_value", alias);
734 build_number_condition(&qty_col, value, param_num)
735 }
736 SearchParamType::Uri => (
737 format!("{}.value_uri = ?{}", alias, param_num),
738 SqlParam::String(value.value.clone()),
739 ),
740 _ => (
741 format!("{}.value_string LIKE ?{}", alias, param_num),
742 SqlParam::String(format!("%{}%", value.value)),
743 ),
744 };
745
746 Ok((condition, param))
747 }
748
749 fn infer_param_type(&self, param_name: &str) -> SearchParamType {
751 match param_name {
752 "name" | "family" | "given" | "text" | "display" | "description" | "address"
753 | "city" | "state" | "country" => SearchParamType::String,
754 "identifier" | "code" | "status" | "type" | "category" | "class" | "gender"
755 | "language" => SearchParamType::Token,
756 "date" | "birthdate" | "issued" | "effective" | "period" | "authored" => {
757 SearchParamType::Date
758 }
759 "patient" | "subject" | "performer" | "author" | "encounter" | "organization"
760 | "practitioner" | "location" => SearchParamType::Reference,
761 "value-quantity" | "dose" | "quantity" => SearchParamType::Quantity,
762 "length" | "count" | "value" => SearchParamType::Number,
763 "url" | "source" => SearchParamType::Uri,
764 _ => SearchParamType::String, }
766 }
767}
768
769fn build_date_condition(column: &str, value: &SearchValue, param_num: usize) -> (String, SqlParam) {
771 use crate::types::SearchPrefix;
772
773 let (op, val) = match value.prefix {
774 SearchPrefix::Eq => ("=", &value.value),
775 SearchPrefix::Ne => ("!=", &value.value),
776 SearchPrefix::Gt => (">", &value.value),
777 SearchPrefix::Lt => ("<", &value.value),
778 SearchPrefix::Ge => (">=", &value.value),
779 SearchPrefix::Le => ("<=", &value.value),
780 SearchPrefix::Sa => (">", &value.value),
781 SearchPrefix::Eb => ("<", &value.value),
782 SearchPrefix::Ap => {
783 return (
785 format!("DATE({}) = DATE(?{})", column, param_num),
786 SqlParam::String(value.value.clone()),
787 );
788 }
789 };
790
791 (
792 format!("{} {} ?{}", column, op, param_num),
793 SqlParam::String(val.clone()),
794 )
795}
796
797fn build_number_condition(
799 column: &str,
800 value: &SearchValue,
801 param_num: usize,
802) -> (String, SqlParam) {
803 use crate::types::SearchPrefix;
804
805 let num_value = value.value.parse::<f64>().unwrap_or(0.0);
807
808 let (op, val) = match value.prefix {
809 SearchPrefix::Eq => ("=", num_value),
810 SearchPrefix::Ne => ("!=", num_value),
811 SearchPrefix::Gt => (">", num_value),
812 SearchPrefix::Lt => ("<", num_value),
813 SearchPrefix::Ge => (">=", num_value),
814 SearchPrefix::Le => ("<=", num_value),
815 SearchPrefix::Sa => (">", num_value),
816 SearchPrefix::Eb => ("<", num_value),
817 SearchPrefix::Ap => {
818 let lower = num_value * 0.9;
820 let upper = num_value * 1.1;
821 return (
822 format!("{} BETWEEN {} AND {}", column, lower, upper),
823 SqlParam::Float(num_value),
824 );
825 }
826 };
827
828 (
829 format!("{} {} ?{}", column, op, param_num),
830 SqlParam::Float(val),
831 )
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::search::SearchParameterDefinition;
838
839 fn create_test_registry() -> Arc<RwLock<SearchParameterRegistry>> {
840 let mut registry = SearchParameterRegistry::new();
841
842 let patient_subject = SearchParameterDefinition::new(
844 "http://hl7.org/fhir/SearchParameter/Observation-subject",
845 "subject",
846 SearchParamType::Reference,
847 "Observation.subject",
848 )
849 .with_base(vec!["Observation"])
850 .with_targets(vec!["Patient"]);
851
852 let patient_org = SearchParameterDefinition::new(
853 "http://hl7.org/fhir/SearchParameter/Patient-organization",
854 "organization",
855 SearchParamType::Reference,
856 "Patient.managingOrganization",
857 )
858 .with_base(vec!["Patient"])
859 .with_targets(vec!["Organization"]);
860
861 let org_name = SearchParameterDefinition::new(
862 "http://hl7.org/fhir/SearchParameter/Organization-name",
863 "name",
864 SearchParamType::String,
865 "Organization.name",
866 )
867 .with_base(vec!["Organization"]);
868
869 let patient_name = SearchParameterDefinition::new(
870 "http://hl7.org/fhir/SearchParameter/Patient-name",
871 "name",
872 SearchParamType::String,
873 "Patient.name",
874 )
875 .with_base(vec!["Patient"]);
876
877 let obs_code = SearchParameterDefinition::new(
878 "http://hl7.org/fhir/SearchParameter/Observation-code",
879 "code",
880 SearchParamType::Token,
881 "Observation.code",
882 )
883 .with_base(vec!["Observation"]);
884
885 registry.register(patient_subject).unwrap();
886 registry.register(patient_org).unwrap();
887 registry.register(org_name).unwrap();
888 registry.register(patient_name).unwrap();
889 registry.register(obs_code).unwrap();
890
891 Arc::new(RwLock::new(registry))
892 }
893
894 #[test]
895 fn test_parse_simple_chain() {
896 let registry = create_test_registry();
897 let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
898
899 let result = builder.parse_chain("subject.name");
900 assert!(result.is_ok());
901
902 let chain = result.unwrap();
903 assert_eq!(chain.links.len(), 1);
904 assert_eq!(chain.links[0].reference_param, "subject");
905 assert_eq!(chain.links[0].target_type, "Patient");
906 assert_eq!(chain.terminal_param, "name");
907 assert_eq!(chain.terminal_type, SearchParamType::String);
908 }
909
910 #[test]
911 fn test_parse_multi_level_chain() {
912 let registry = create_test_registry();
913 let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
914
915 let result = builder.parse_chain("subject.organization.name");
916 assert!(result.is_ok());
917
918 let chain = result.unwrap();
919 assert_eq!(chain.links.len(), 2);
920 assert_eq!(chain.links[0].reference_param, "subject");
921 assert_eq!(chain.links[0].target_type, "Patient");
922 assert_eq!(chain.links[1].reference_param, "organization");
923 assert_eq!(chain.links[1].target_type, "Organization");
924 assert_eq!(chain.terminal_param, "name");
925 }
926
927 #[test]
928 fn test_parse_chain_with_type_modifier() {
929 let registry = create_test_registry();
930 let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
931
932 let result = builder.parse_chain("subject:Patient.name");
933 assert!(result.is_ok());
934
935 let chain = result.unwrap();
936 assert_eq!(chain.links[0].target_type, "Patient");
937 }
938
939 #[test]
940 fn test_max_depth_exceeded() {
941 let registry = create_test_registry();
942 let builder = ChainQueryBuilder::new("tenant1", "Observation", registry)
943 .with_config(ChainConfig::new(2, 2));
944
945 let result = builder.parse_chain("a.b.c.d"); assert!(matches!(
947 result,
948 Err(ChainError::MaxDepthExceeded { depth: 3, max: 2 })
949 ));
950 }
951
952 #[test]
953 fn test_build_forward_chain_sql() {
954 let registry = create_test_registry();
955 let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
956
957 let chain = builder.parse_chain("subject.name").unwrap();
958 let value = SearchValue::eq("Smith");
959
960 let result = builder.build_forward_chain_sql(&chain, &value);
961 assert!(result.is_ok());
962
963 let fragment = result.unwrap();
964 assert!(fragment.sql.contains("r.id IN"));
965 assert!(fragment.sql.contains("search_index"));
966 assert!(fragment.sql.contains("subject"));
967 assert!(fragment.sql.contains("name"));
968 }
969
970 #[test]
971 fn test_build_reverse_chain_sql() {
972 let registry = create_test_registry();
973 let builder = ChainQueryBuilder::new("tenant1", "Patient", registry);
974
975 let rc = ReverseChainedParameter::terminal(
976 "Observation",
977 "subject",
978 "code",
979 SearchValue::eq("1234-5"),
980 );
981
982 let result = builder.build_reverse_chain_sql(&rc);
983 assert!(result.is_ok());
984
985 let fragment = result.unwrap();
986 assert!(fragment.sql.contains("r.id IN"));
987 assert!(fragment.sql.contains("Observation"));
988 assert!(fragment.sql.contains("subject"));
989 assert!(fragment.sql.contains("code"));
990 assert!(fragment.sql.contains("Patient/%"));
991 }
992
993 #[test]
994 fn test_reverse_chain_depth() {
995 let inner = ReverseChainedParameter::terminal(
996 "Provenance",
997 "target",
998 "agent",
999 SearchValue::eq("Practitioner/123"),
1000 );
1001 let outer = ReverseChainedParameter::nested("Observation", "subject", inner);
1002
1003 assert_eq!(outer.depth(), 2);
1004 assert!(!outer.is_terminal());
1005 }
1006}