1use 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 {
48 depth: usize,
50 max: usize,
52 },
53 UnknownReferenceParam {
55 resource_type: String,
57 param: String,
59 },
60 UnknownTerminalParam {
62 resource_type: String,
64 param: String,
66 },
67 EmptyChain,
69 InvalidSyntax {
71 message: String,
73 },
74}
75
76impl std::fmt::Display for ChainError {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 ChainError::MaxDepthExceeded { depth, max } => {
80 write!(
81 f,
82 "Chain depth {} exceeds maximum allowed depth {}",
83 depth, max
84 )
85 }
86 ChainError::UnknownReferenceParam {
87 resource_type,
88 param,
89 } => write!(
90 f,
91 "Unknown reference parameter '{}' for resource type '{}'",
92 param, resource_type
93 ),
94 ChainError::UnknownTerminalParam {
95 resource_type,
96 param,
97 } => write!(
98 f,
99 "Unknown terminal parameter '{}' for resource type '{}'",
100 param, resource_type
101 ),
102 ChainError::EmptyChain => write!(f, "Empty chain"),
103 ChainError::InvalidSyntax { message } => write!(f, "Invalid chain syntax: {}", message),
104 }
105 }
106}
107
108impl From<ChainError> for BackendError {
109 fn from(e: ChainError) -> Self {
110 BackendError::Internal {
111 backend_name: "postgres".to_string(),
112 message: e.to_string(),
113 source: None,
114 }
115 }
116}
117
118pub struct ChainQueryBuilder {
120 #[allow(dead_code)]
121 tenant_id: String,
122 base_type: String,
123 registry: Arc<RwLock<SearchParameterRegistry>>,
124 config: ChainConfig,
125 param_offset: usize,
130}
131
132impl ChainQueryBuilder {
133 pub fn new(
135 tenant_id: impl Into<String>,
136 base_type: impl Into<String>,
137 registry: Arc<RwLock<SearchParameterRegistry>>,
138 ) -> Self {
139 Self {
140 tenant_id: tenant_id.into(),
141 base_type: base_type.into(),
142 registry,
143 config: ChainConfig::default(),
144 param_offset: 1,
145 }
146 }
147
148 pub fn with_config(mut self, config: ChainConfig) -> Self {
150 self.config = config;
151 self
152 }
153
154 pub fn with_param_offset(mut self, offset: usize) -> Self {
156 self.param_offset = offset;
157 self
158 }
159
160 pub fn parse_chain(&self, chain_str: &str) -> Result<ParsedChain, ChainError> {
163 if chain_str.is_empty() {
164 return Err(ChainError::EmptyChain);
165 }
166
167 let parts: Vec<&str> = chain_str.split('.').collect();
168 if parts.len() < 2 {
169 return Err(ChainError::InvalidSyntax {
170 message: "Chain must have at least two parts (reference.param)".to_string(),
171 });
172 }
173
174 let chain_depth = parts.len() - 1;
175 if !self.config.validate_forward_depth(chain_depth) {
176 return Err(ChainError::MaxDepthExceeded {
177 depth: chain_depth,
178 max: self.config.max_forward_depth,
179 });
180 }
181
182 let mut links = Vec::new();
183 let mut current_type = self.base_type.clone();
184
185 for part in parts.iter().take(parts.len() - 1) {
186 let (ref_param, explicit_type) = parse_chain_part(part);
187 let target_type = self.resolve_target_type(¤t_type, &ref_param, explicit_type)?;
188 links.push(ChainLink {
189 reference_param: ref_param,
190 target_type: target_type.clone(),
191 });
192 current_type = target_type;
193 }
194
195 let terminal_param = parts[parts.len() - 1].to_string();
196 let terminal_type = self.resolve_terminal_type(¤t_type, &terminal_param)?;
197
198 Ok(ParsedChain {
199 links,
200 terminal_param,
201 terminal_type,
202 })
203 }
204
205 fn resolve_target_type(
206 &self,
207 resource_type: &str,
208 ref_param: &str,
209 explicit_type: Option<String>,
210 ) -> Result<String, ChainError> {
211 if let Some(t) = explicit_type {
212 return Ok(t);
213 }
214
215 let registry = self.registry.read();
216 if let Some(param_def) = registry.get_param(resource_type, ref_param) {
217 if param_def.param_type != SearchParamType::Reference {
218 return Err(ChainError::UnknownReferenceParam {
219 resource_type: resource_type.to_string(),
220 param: ref_param.to_string(),
221 });
222 }
223 if let Some(ref targets) = param_def.target {
224 if targets.len() == 1 {
225 return Ok(targets[0].clone());
226 }
227 }
232 }
233
234 Ok(crate::search::chain_resolver::infer_target_type(ref_param))
235 }
236
237 fn resolve_terminal_type(
238 &self,
239 resource_type: &str,
240 param_name: &str,
241 ) -> Result<SearchParamType, ChainError> {
242 let registry = self.registry.read();
243 if let Some(param_def) = registry.get_param(resource_type, param_name) {
244 return Ok(param_def.param_type);
245 }
246 match param_name {
248 "_id" | "id" => Ok(SearchParamType::Token),
249 "name" | "family" | "given" | "text" | "display" => Ok(SearchParamType::String),
250 "identifier" | "code" | "status" | "type" | "category" => Ok(SearchParamType::Token),
251 _ => Err(ChainError::UnknownTerminalParam {
252 resource_type: resource_type.to_string(),
253 param: param_name.to_string(),
254 }),
255 }
256 }
257
258 pub fn build_forward_chain_sql(
282 &self,
283 chain: &ParsedChain,
284 value: &SearchValue,
285 ) -> StorageResult<SqlFragment> {
286 if chain.links.is_empty() {
287 return Err(BackendError::Internal {
288 backend_name: "postgres".to_string(),
289 message: "Empty chain".to_string(),
290 source: None,
291 }
292 .into());
293 }
294
295 let param_num = self.param_offset + 1;
296 let (terminal_sql, terminal_param) =
297 self.build_terminal_condition(chain, value, param_num)?;
298 let terminal_type = &chain.links[chain.links.len() - 1].target_type;
299
300 let mut current_sql = format!(
302 "SELECT '{tt}/' || si{n}.resource_id FROM search_index si{n} \
303 WHERE si{n}.tenant_id = $1 AND si{n}.resource_type = '{tt}' \
304 AND si{n}.param_name = '{tp}' AND {cond}",
305 tt = terminal_type,
306 n = chain.links.len(),
307 tp = chain.terminal_param,
308 cond = terminal_sql,
309 );
310
311 for (i, link) in chain.links.iter().enumerate().rev() {
313 let link_num = i + 1;
314 let current_type = if i == 0 {
315 &self.base_type
316 } else {
317 &chain.links[i - 1].target_type
318 };
319
320 current_sql = if i == 0 {
321 format!(
323 "SELECT si{ln}.resource_id FROM search_index si{ln} \
324 WHERE si{ln}.tenant_id = $1 AND si{ln}.resource_type = '{ct}' \
325 AND si{ln}.param_name = '{rp}' \
326 AND si{ln}.value_reference IN ({inner})",
327 ln = link_num,
328 ct = current_type,
329 rp = link.reference_param,
330 inner = current_sql,
331 )
332 } else {
333 format!(
335 "SELECT '{ct}/' || si{ln}.resource_id FROM search_index si{ln} \
336 WHERE si{ln}.tenant_id = $1 AND si{ln}.resource_type = '{ct}' \
337 AND si{ln}.param_name = '{rp}' \
338 AND si{ln}.value_reference IN ({inner})",
339 ct = current_type,
340 ln = link_num,
341 rp = link.reference_param,
342 inner = current_sql,
343 )
344 };
345 }
346
347 Ok(SqlFragment::with_params(
348 format!("r.id IN ({})", current_sql),
349 vec![terminal_param],
350 ))
351 }
352
353 fn build_terminal_condition(
354 &self,
355 chain: &ParsedChain,
356 value: &SearchValue,
357 param_num: usize,
358 ) -> StorageResult<(String, SqlParam)> {
359 let alias = format!("si{}", chain.links.len());
360
361 let (condition, param) = match chain.terminal_type {
362 SearchParamType::String => {
363 let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
364 (
365 format!("{}.value_string ILIKE ${} ESCAPE '\\'", alias, param_num),
366 SqlParam::Text(format!("%{}%", escaped)),
367 )
368 }
369 SearchParamType::Token => {
370 if let Some((system, code)) = value.value.split_once('|') {
371 if system.is_empty() {
372 (
373 format!(
374 "({alias}.value_token_system IS NULL OR {alias}.value_token_system = '') \
375 AND {alias}.value_token_code = ${pn}",
376 alias = alias,
377 pn = param_num,
378 ),
379 SqlParam::Text(code.to_string()),
380 )
381 } else {
382 (
383 format!(
384 "{alias}.value_token_system = '{sys}' AND {alias}.value_token_code = ${pn}",
385 alias = alias,
386 sys = system.replace('\'', "''"),
387 pn = param_num,
388 ),
389 SqlParam::Text(code.to_string()),
390 )
391 }
392 } else {
393 (
394 format!("{}.value_token_code = ${}", alias, param_num),
395 SqlParam::Text(value.value.clone()),
396 )
397 }
398 }
399 SearchParamType::Reference => (
400 format!("{}.value_reference ILIKE ${}", alias, param_num),
401 SqlParam::Text(format!("%{}%", value.value)),
402 ),
403 SearchParamType::Date => {
404 let date_col = format!("{}.value_date", alias);
405 build_date_condition(&date_col, value, param_num)
406 }
407 SearchParamType::Number => {
408 let num_col = format!("{}.value_number", alias);
409 build_number_condition(&num_col, value, param_num)
410 }
411 SearchParamType::Quantity => {
412 let qty_col = format!("{}.value_quantity_value", alias);
413 build_number_condition(&qty_col, value, param_num)
414 }
415 SearchParamType::Uri => (
416 format!("{}.value_uri = ${}", alias, param_num),
417 SqlParam::Text(value.value.clone()),
418 ),
419 _ => (
420 format!("{}.value_string ILIKE ${}", alias, param_num),
421 SqlParam::Text(format!("%{}%", value.value)),
422 ),
423 };
424
425 Ok((condition, param))
426 }
427
428 pub fn build_reverse_chain_sql(
448 &self,
449 reverse_chain: &ReverseChainedParameter,
450 ) -> StorageResult<SqlFragment> {
451 let depth = reverse_chain.depth();
452 if !self.config.validate_reverse_depth(depth) {
453 return Err(BackendError::Internal {
454 backend_name: "postgres".to_string(),
455 message: format!(
456 "Reverse chain depth {} exceeds maximum {}",
457 depth, self.config.max_reverse_depth
458 ),
459 source: None,
460 }
461 .into());
462 }
463
464 let param_num = self.param_offset + 1;
465 let (sql, params) = self.build_reverse_chain_recursive(reverse_chain, 1, param_num)?;
466
467 Ok(SqlFragment::with_params(
468 format!("r.id IN ({})", sql),
469 params,
470 ))
471 }
472
473 fn build_reverse_chain_recursive(
474 &self,
475 rc: &ReverseChainedParameter,
476 depth: usize,
477 param_num: usize,
478 ) -> StorageResult<(String, Vec<SqlParam>)> {
479 let alias = format!("si{}", depth);
480
481 if rc.is_terminal() {
482 let value = rc.value.as_ref().ok_or_else(|| BackendError::Internal {
483 backend_name: "postgres".to_string(),
484 message: "Terminal reverse chain must have a value".to_string(),
485 source: None,
486 })?;
487
488 let (search_condition, search_param) = self.build_reverse_terminal_condition(
489 &rc.source_type,
490 &rc.search_param,
491 value,
492 depth + 1,
493 param_num,
494 )?;
495
496 let depth2 = depth + 1;
497 let sql = format!(
498 "SELECT SUBSTRING({alias}.value_reference FROM POSITION('/' IN {alias}.value_reference) + 1) \
499 FROM search_index {alias} \
500 WHERE {alias}.tenant_id = $1 AND {alias}.resource_type = '{src_type}' \
501 AND {alias}.param_name = '{ref_param}' \
502 AND {alias}.value_reference LIKE '{base_type}/%' \
503 AND {alias}.resource_id IN (\
504 SELECT si{depth2}.resource_id FROM search_index si{depth2} \
505 WHERE si{depth2}.tenant_id = $1 AND si{depth2}.resource_type = '{src_type}' \
506 AND si{depth2}.param_name = '{search_param_name}' AND {search_condition}\
507 )",
508 alias = alias,
509 src_type = rc.source_type,
510 ref_param = rc.reference_param,
511 base_type = self.base_type,
512 depth2 = depth2,
513 search_param_name = rc.search_param,
514 search_condition = search_condition,
515 );
516
517 Ok((sql, vec![search_param]))
518 } else {
519 let inner = rc.nested.as_ref().ok_or_else(|| BackendError::Internal {
520 backend_name: "postgres".to_string(),
521 message: "Non-terminal reverse chain must have nested chain".to_string(),
522 source: None,
523 })?;
524
525 let inner_builder = ChainQueryBuilder::new(
526 &self.tenant_id,
527 &rc.source_type,
528 Arc::clone(&self.registry),
529 )
530 .with_config(self.config.clone())
531 .with_param_offset(param_num - 1);
532
533 let (inner_sql, inner_params) =
534 inner_builder.build_reverse_chain_recursive(inner, depth + 1, param_num)?;
535
536 let sql = format!(
537 "SELECT SUBSTRING({alias}.value_reference FROM POSITION('/' IN {alias}.value_reference) + 1) \
538 FROM search_index {alias} \
539 WHERE {alias}.tenant_id = $1 AND {alias}.resource_type = '{}' \
540 AND {alias}.param_name = '{}' \
541 AND {alias}.value_reference LIKE '{}/%' \
542 AND {alias}.resource_id IN ({inner_sql})",
543 rc.source_type,
544 rc.reference_param,
545 self.base_type,
546 alias = alias,
547 );
548
549 Ok((sql, inner_params))
550 }
551 }
552
553 fn build_reverse_terminal_condition(
554 &self,
555 resource_type: &str,
556 param_name: &str,
557 value: &SearchValue,
558 depth: usize,
559 param_num: usize,
560 ) -> StorageResult<(String, SqlParam)> {
561 let param_type = {
562 let registry = self.registry.read();
563 crate::search::resolve_param_type(
564 ®istry,
565 resource_type,
566 param_name,
567 std::slice::from_ref(value),
568 )
569 };
570
571 let alias = format!("si{}", depth);
572
573 let (condition, param) = match param_type {
574 SearchParamType::String => {
575 let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
576 (
577 format!("{}.value_string ILIKE ${} ESCAPE '\\'", alias, param_num),
578 SqlParam::Text(format!("%{}%", escaped)),
579 )
580 }
581 SearchParamType::Token => {
582 if let Some((system, code)) = value.value.split_once('|') {
583 if system.is_empty() {
584 (
585 format!(
586 "({alias}.value_token_system IS NULL OR {alias}.value_token_system = '') \
587 AND {alias}.value_token_code = ${pn}",
588 alias = alias,
589 pn = param_num,
590 ),
591 SqlParam::Text(code.to_string()),
592 )
593 } else {
594 (
595 format!(
596 "{alias}.value_token_system = '{sys}' AND {alias}.value_token_code = ${pn}",
597 alias = alias,
598 sys = system.replace('\'', "''"),
599 pn = param_num,
600 ),
601 SqlParam::Text(code.to_string()),
602 )
603 }
604 } else {
605 (
606 format!("{}.value_token_code = ${}", alias, param_num),
607 SqlParam::Text(value.value.clone()),
608 )
609 }
610 }
611 SearchParamType::Reference => (
612 format!("{}.value_reference ILIKE ${}", alias, param_num),
613 SqlParam::Text(format!("%{}%", value.value)),
614 ),
615 SearchParamType::Date => {
616 let date_col = format!("{}.value_date", alias);
617 build_date_condition(&date_col, value, param_num)
618 }
619 SearchParamType::Number => {
620 let num_col = format!("{}.value_number", alias);
621 build_number_condition(&num_col, value, param_num)
622 }
623 SearchParamType::Quantity => {
624 let qty_col = format!("{}.value_quantity_value", alias);
625 build_number_condition(&qty_col, value, param_num)
626 }
627 SearchParamType::Uri => (
628 format!("{}.value_uri = ${}", alias, param_num),
629 SqlParam::Text(value.value.clone()),
630 ),
631 _ => (
632 format!("{}.value_string ILIKE ${}", alias, param_num),
633 SqlParam::Text(format!("%{}%", value.value)),
634 ),
635 };
636
637 Ok((condition, param))
638 }
639}
640
641fn parse_chain_part(part: &str) -> (String, Option<String>) {
642 if let Some((param, type_mod)) = part.split_once(':') {
643 (param.to_string(), Some(type_mod.to_string()))
644 } else {
645 (part.to_string(), None)
646 }
647}
648
649fn build_date_condition(column: &str, value: &SearchValue, param_num: usize) -> (String, SqlParam) {
650 use crate::types::SearchPrefix;
651
652 let (op, val) = match value.prefix {
653 SearchPrefix::Eq => ("=", &value.value),
654 SearchPrefix::Ne => ("!=", &value.value),
655 SearchPrefix::Gt => (">", &value.value),
656 SearchPrefix::Lt => ("<", &value.value),
657 SearchPrefix::Ge => (">=", &value.value),
658 SearchPrefix::Le => ("<=", &value.value),
659 SearchPrefix::Sa => (">", &value.value),
660 SearchPrefix::Eb => ("<", &value.value),
661 SearchPrefix::Ap => {
662 return (
663 format!("DATE({}) = DATE(${})", column, param_num),
664 SqlParam::Text(value.value.clone()),
665 );
666 }
667 };
668
669 (
670 format!("{} {} ${}", column, op, param_num),
671 SqlParam::Text(val.clone()),
672 )
673}
674
675fn build_number_condition(
676 column: &str,
677 value: &SearchValue,
678 param_num: usize,
679) -> (String, SqlParam) {
680 use crate::types::SearchPrefix;
681
682 let num_value = value.value.parse::<f64>().unwrap_or(0.0);
683
684 let (op, val) = match value.prefix {
685 SearchPrefix::Eq => ("=", num_value),
686 SearchPrefix::Ne => ("!=", num_value),
687 SearchPrefix::Gt => (">", num_value),
688 SearchPrefix::Lt => ("<", num_value),
689 SearchPrefix::Ge => (">=", num_value),
690 SearchPrefix::Le => ("<=", num_value),
691 SearchPrefix::Sa => (">", num_value),
692 SearchPrefix::Eb => ("<", num_value),
693 SearchPrefix::Ap => {
694 let lower = num_value * 0.9;
695 let upper = num_value * 1.1;
696 return (
697 format!("{} BETWEEN {} AND {}", column, lower, upper),
698 SqlParam::Float(num_value),
699 );
700 }
701 };
702
703 (
704 format!("{} {} ${}", column, op, param_num),
705 SqlParam::Float(val),
706 )
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::search::SearchParameterDefinition;
713
714 fn registry_with(defs: Vec<SearchParameterDefinition>) -> Arc<RwLock<SearchParameterRegistry>> {
715 let mut r = SearchParameterRegistry::new();
716 for d in defs {
717 r.register(d).unwrap();
718 }
719 Arc::new(RwLock::new(r))
720 }
721
722 fn obs_subject_patient_org_name() -> Arc<RwLock<SearchParameterRegistry>> {
723 registry_with(vec![
724 SearchParameterDefinition::new(
725 "http://hl7.org/fhir/SearchParameter/Observation-subject",
726 "subject",
727 SearchParamType::Reference,
728 "Observation.subject",
729 )
730 .with_base(vec!["Observation"])
731 .with_targets(vec!["Patient"]),
732 SearchParameterDefinition::new(
733 "http://hl7.org/fhir/SearchParameter/Patient-organization",
734 "organization",
735 SearchParamType::Reference,
736 "Patient.managingOrganization",
737 )
738 .with_base(vec!["Patient"])
739 .with_targets(vec!["Organization"]),
740 SearchParameterDefinition::new(
741 "http://hl7.org/fhir/SearchParameter/Organization-name",
742 "name",
743 SearchParamType::String,
744 "Organization.name",
745 )
746 .with_base(vec!["Organization"]),
747 ])
748 }
749
750 #[test]
751 fn parses_three_link_chain() {
752 let registry = obs_subject_patient_org_name();
753 let builder = ChainQueryBuilder::new("t", "Observation", registry);
754 let parsed = builder.parse_chain("subject.organization.name").unwrap();
755 assert_eq!(parsed.links.len(), 2);
756 assert_eq!(parsed.links[0].reference_param, "subject");
757 assert_eq!(parsed.links[0].target_type, "Patient");
758 assert_eq!(parsed.links[1].reference_param, "organization");
759 assert_eq!(parsed.links[1].target_type, "Organization");
760 assert_eq!(parsed.terminal_param, "name");
761 assert_eq!(parsed.terminal_type, SearchParamType::String);
762 }
763
764 #[test]
765 fn builds_three_link_chain_sql() {
766 let registry = obs_subject_patient_org_name();
767 let builder = ChainQueryBuilder::new("t", "Observation", registry);
768 let parsed = builder.parse_chain("subject.organization.name").unwrap();
769 let value = SearchValue::eq("Hospital");
770 let frag = builder.build_forward_chain_sql(&parsed, &value).unwrap();
771
772 assert!(frag.sql.contains("r.id IN ("));
773 assert_eq!(frag.sql.matches("FROM search_index").count(), 3);
778 assert!(frag.sql.contains("SELECT si1.resource_id"));
779 assert!(frag.sql.contains("'Patient/' || si2.resource_id"));
780 assert!(frag.sql.contains("'Organization/' || si2.resource_id"));
781 assert!(frag.sql.contains("ILIKE $2 ESCAPE '\\'"));
782 assert_eq!(frag.params.len(), 1);
783 assert!(matches!(&frag.params[0], SqlParam::Text(s) if s == "%Hospital%"));
784 }
785
786 #[test]
787 fn explicit_type_modifier_is_honored() {
788 let registry = registry_with(vec![
790 SearchParameterDefinition::new(
791 "http://hl7.org/fhir/SearchParameter/Observation-subject",
792 "subject",
793 SearchParamType::Reference,
794 "Observation.subject",
795 )
796 .with_base(vec!["Observation"])
797 .with_targets(vec!["Patient", "Group", "Device", "Location"]),
798 SearchParameterDefinition::new(
799 "http://hl7.org/fhir/SearchParameter/Patient-name",
800 "name",
801 SearchParamType::String,
802 "Patient.name",
803 )
804 .with_base(vec!["Patient"]),
805 ]);
806 let builder = ChainQueryBuilder::new("t", "Observation", registry);
807 let parsed = builder.parse_chain("subject:Patient.name").unwrap();
808 assert_eq!(parsed.links[0].target_type, "Patient");
809 }
810
811 #[test]
812 fn ambiguous_target_falls_back_to_inference() {
813 let registry = registry_with(vec![
814 SearchParameterDefinition::new(
815 "http://hl7.org/fhir/SearchParameter/Observation-subject",
816 "subject",
817 SearchParamType::Reference,
818 "Observation.subject",
819 )
820 .with_base(vec!["Observation"])
821 .with_targets(vec!["Patient", "Group", "Device", "Location"]),
822 SearchParameterDefinition::new(
823 "http://hl7.org/fhir/SearchParameter/Patient-name",
824 "name",
825 SearchParamType::String,
826 "Patient.name",
827 )
828 .with_base(vec!["Patient"]),
829 ]);
830 let builder = ChainQueryBuilder::new("t", "Observation", registry);
831 let parsed = builder.parse_chain("subject.name").unwrap();
832 assert_eq!(parsed.links[0].target_type, "Patient"); }
834
835 #[test]
836 fn empty_chain_errors() {
837 let registry = obs_subject_patient_org_name();
838 let builder = ChainQueryBuilder::new("t", "Observation", registry);
839 assert!(matches!(
840 builder.parse_chain(""),
841 Err(ChainError::EmptyChain)
842 ));
843 assert!(matches!(
844 builder.parse_chain("just_one_part"),
845 Err(ChainError::InvalidSyntax { .. })
846 ));
847 }
848
849 #[test]
850 fn reverse_chain_terminal_sql_uses_substring_position() {
851 let rc = ReverseChainedParameter {
853 source_type: "Observation".to_string(),
854 reference_param: "subject".to_string(),
855 search_param: "code".to_string(),
856 value: Some(SearchValue::eq("1234-5")),
857 nested: None,
858 };
859 let registry = registry_with(vec![
860 SearchParameterDefinition::new(
861 "http://hl7.org/fhir/SearchParameter/Observation-code",
862 "code",
863 SearchParamType::Token,
864 "Observation.code",
865 )
866 .with_base(vec!["Observation"]),
867 ]);
868 let builder = ChainQueryBuilder::new("t", "Patient", registry);
869 let frag = builder.build_reverse_chain_sql(&rc).unwrap();
870 assert!(frag.sql.contains(
871 "SUBSTRING(si1.value_reference FROM POSITION('/' IN si1.value_reference) + 1)"
872 ));
873 assert!(frag.sql.contains("LIKE 'Patient/%'"));
874 assert!(frag.sql.contains("value_token_code = $2"));
875 }
876}