Skip to main content

helios_persistence/backends/postgres/search/
chain_builder.rs

1//! Chain Query Builder for FHIR Search (PostgreSQL backend).
2//!
3//! Generates efficient SQL subqueries for:
4//! - Forward chained parameters (e.g., `Observation?subject.organization.name=Hospital`)
5//! - Reverse chained parameters (`_has`) (e.g., `Patient?_has:Observation:subject:code=1234-5`)
6//!
7//! Uses the `search_index` table to resolve chains via SQL subqueries instead
8//! of in-memory iteration. Mirrors the SQLite implementation in
9//! `crates/persistence/src/backends/sqlite/search/chain_builder.rs` with
10//! Postgres syntax adaptations: `$N` placeholders, `ILIKE`, `POSITION(... in ...)`
11//! for substring index, and `LIKE ESCAPE '\'`.
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/// A single link in a forward chain.
24#[derive(Debug, Clone)]
25pub struct ChainLink {
26    /// Reference parameter being chained through.
27    pub reference_param: String,
28    /// Target resource type resolved from the registry or explicit modifier.
29    pub target_type: String,
30}
31
32/// A parsed forward chain with resolved types.
33#[derive(Debug, Clone)]
34pub struct ParsedChain {
35    /// Chain links from base to target.
36    pub links: Vec<ChainLink>,
37    /// Terminal parameter name to search on.
38    pub terminal_param: String,
39    /// Search parameter type of the terminal parameter.
40    pub terminal_type: SearchParamType,
41}
42
43/// Errors specific to chain parsing.
44#[derive(Debug, Clone)]
45pub enum ChainError {
46    /// Chain exceeds maximum allowed depth.
47    MaxDepthExceeded {
48        /// Depth of the chain that was rejected.
49        depth: usize,
50        /// Configured maximum forward-chain depth.
51        max: usize,
52    },
53    /// Reference parameter not found in registry.
54    UnknownReferenceParam {
55        /// Resource type the reference parameter was looked up against.
56        resource_type: String,
57        /// Reference parameter name.
58        param: String,
59    },
60    /// Terminal parameter not found.
61    UnknownTerminalParam {
62        /// Resource type the terminal parameter was looked up against.
63        resource_type: String,
64        /// Terminal parameter name.
65        param: String,
66    },
67    /// Chain is empty.
68    EmptyChain,
69    /// Invalid chain syntax.
70    InvalidSyntax {
71        /// Human-readable parser failure detail.
72        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
118/// Builder for chain SQL queries.
119pub struct ChainQueryBuilder {
120    #[allow(dead_code)]
121    tenant_id: String,
122    base_type: String,
123    registry: Arc<RwLock<SearchParameterRegistry>>,
124    config: ChainConfig,
125    /// Parameter offset for `$N` placeholders.
126    ///
127    /// Callers typically reserve `$1` for `tenant_id`, so the default offset
128    /// of `1` makes the first chain-supplied param `$2`.
129    param_offset: usize,
130}
131
132impl ChainQueryBuilder {
133    /// Creates a new chain query builder rooted at `base_type` in the given tenant.
134    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    /// Sets the chain depth configuration.
149    pub fn with_config(mut self, config: ChainConfig) -> Self {
150        self.config = config;
151        self
152    }
153
154    /// Sets the parameter offset used when allocating `$N` placeholders.
155    pub fn with_param_offset(mut self, offset: usize) -> Self {
156        self.param_offset = offset;
157        self
158    }
159
160    /// Parses a chain string (e.g., `"subject.organization.name"`) into
161    /// resolved `ChainLink`s plus the terminal parameter.
162    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(&current_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(&current_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                // Empty or multiple targets — fall through to inference,
228                // matching SQLite's behavior so chained queries against
229                // ambiguous references (e.g. `subject` -> Patient|Group|...)
230                // pick the same default both backends agree on.
231            }
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        // Last-resort heuristic for params not in the registry, matching SQLite.
247        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    /// Builds SQL for a forward chain query as nested subqueries.
259    ///
260    /// For `Observation?subject.organization.name=Hospital` (assuming
261    /// `param_offset = 1`, so `$1` is `tenant_id`):
262    ///
263    /// ```sql
264    /// r.id IN (
265    ///   SELECT si1.resource_id FROM search_index si1
266    ///   WHERE si1.tenant_id = $1 AND si1.resource_type = 'Observation'
267    ///     AND si1.param_name = 'subject'
268    ///     AND si1.value_reference IN (
269    ///       SELECT 'Patient/' || si2.resource_id FROM search_index si2
270    ///       WHERE si2.tenant_id = $1 AND si2.resource_type = 'Patient'
271    ///         AND si2.param_name = 'organization'
272    ///         AND si2.value_reference IN (
273    ///           SELECT 'Organization/' || si3.resource_id FROM search_index si3
274    ///           WHERE si3.tenant_id = $1 AND si3.resource_type = 'Organization'
275    ///             AND si3.param_name = 'name'
276    ///             AND si3.value_string ILIKE $2 ESCAPE '\'
277    ///         )
278    ///     )
279    /// )
280    /// ```
281    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        // Innermost (terminal) query.
301        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        // Wrap with each chain link from innermost to outermost.
312        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                // Outermost link: return resource_id for `r.id IN (...)`.
322                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                // Intermediate link: return '{type}/' || resource_id for value_reference matching.
334                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    /// Builds SQL for a reverse chain (`_has`) query.
429    ///
430    /// For `Patient?_has:Observation:subject:code=1234-5`:
431    ///
432    /// ```sql
433    /// r.id IN (
434    ///   SELECT SUBSTRING(si1.value_reference FROM POSITION('/' IN si1.value_reference) + 1)
435    ///   FROM search_index si1
436    ///   WHERE si1.tenant_id = $1 AND si1.resource_type = 'Observation'
437    ///     AND si1.param_name = 'subject'
438    ///     AND si1.value_reference LIKE 'Patient/%'
439    ///     AND si1.resource_id IN (
440    ///       SELECT si2.resource_id FROM search_index si2
441    ///       WHERE si2.tenant_id = $1 AND si2.resource_type = 'Observation'
442    ///         AND si2.param_name = 'code'
443    ///         AND si2.value_token_code = $2
444    ///     )
445    /// )
446    /// ```
447    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                &registry,
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        // Three nested SELECTs (outermost link, intermediate link, terminal).
774        // Aliases are si{i+1} per link plus si{links.len()} for the terminal —
775        // for a 2-link chain that is si1 (subject), si2 (organization), si2
776        // (terminal name); the inner si2 lexically shadows the outer si2.
777        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        // subject:Patient.name picks Patient even if registry has multiple targets.
789        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"); // inferred default
833    }
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        // Patient?_has:Observation:subject:code=1234-5
852        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}