Skip to main content

helios_persistence/backends/sqlite/search/
chain_builder.rs

1//! Chain Query Builder for FHIR Search.
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 efficiently via SQL subqueries
8//! instead of in-memory iteration.
9
10// Error enum variant fields are self-documenting
11#![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/// A single link in a forward chain.
24#[derive(Debug, Clone)]
25pub struct ChainLink {
26    /// The reference parameter being chained through.
27    pub reference_param: String,
28    /// The target resource type (resolved from 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    /// The chain links from base to target.
36    pub links: Vec<ChainLink>,
37    /// The terminal parameter name to search on.
38    pub terminal_param: String,
39    /// The type of the terminal parameter.
40    pub terminal_type: SearchParamType,
41}
42
43/// Error types specific to chain parsing.
44#[derive(Debug, Clone)]
45pub enum ChainError {
46    /// Chain exceeds maximum allowed depth.
47    MaxDepthExceeded { depth: usize, max: usize },
48    /// Reference parameter not found in registry.
49    UnknownReferenceParam {
50        resource_type: String,
51        param: String,
52    },
53    /// Cannot determine target type for reference.
54    AmbiguousTargetType {
55        resource_type: String,
56        param: String,
57    },
58    /// Terminal parameter not found.
59    UnknownTerminalParam {
60        resource_type: String,
61        param: String,
62    },
63    /// Chain is empty.
64    EmptyChain,
65    /// Invalid chain syntax.
66    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
125/// Builder for chain SQL queries.
126///
127/// Uses the SearchParameterRegistry to resolve target types for reference
128/// parameters and generates efficient SQL subqueries.
129pub struct ChainQueryBuilder {
130    /// Tenant ID for the query.
131    tenant_id: String,
132    /// Base resource type being searched.
133    base_type: String,
134    /// Search parameter registry for type resolution.
135    registry: Arc<RwLock<SearchParameterRegistry>>,
136    /// Chain depth configuration.
137    config: ChainConfig,
138    /// Parameter offset for SQL placeholders.
139    param_offset: usize,
140}
141
142impl ChainQueryBuilder {
143    /// Creates a new chain query builder.
144    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, // Default: after ?1 (tenant) and ?2 (resource_type)
155        }
156    }
157
158    /// Sets the chain configuration.
159    pub fn with_config(mut self, config: ChainConfig) -> Self {
160        self.config = config;
161        self
162    }
163
164    /// Sets the parameter offset for SQL placeholders.
165    pub fn with_param_offset(mut self, offset: usize) -> Self {
166        self.param_offset = offset;
167        self
168    }
169
170    /// Parses a chain string into structured chain links.
171    ///
172    /// # Arguments
173    ///
174    /// * `chain_str` - The chain path (e.g., "subject.organization.name" or "subject:Patient.name")
175    ///
176    /// # Returns
177    ///
178    /// A `ParsedChain` with resolved types, or an error if parsing fails.
179    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        // Check depth limit
192        let chain_depth = parts.len() - 1; // Last part is terminal param, not a chain link
193        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        // Process all parts except the last (which is the terminal parameter)
204        for part in parts.iter().take(parts.len() - 1) {
205            let (ref_param, explicit_type) = self.parse_chain_part(part);
206
207            // Resolve the target type
208            let target_type = self.resolve_target_type(&current_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        // Get the terminal parameter
219        let terminal_param = parts[parts.len() - 1].to_string();
220        let terminal_type = self.resolve_terminal_type(&current_type, &terminal_param)?;
221
222        Ok(ParsedChain {
223            links,
224            terminal_param,
225            terminal_type,
226        })
227    }
228
229    /// Parses a chain part, extracting type modifier if present.
230    ///
231    /// E.g., "subject:Patient" returns ("subject", Some("Patient"))
232    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    /// Resolves the target type for a reference parameter.
241    ///
242    /// Uses the registry to find the parameter definition and its targets.
243    /// If the parameter has multiple targets and no explicit type is given,
244    /// falls back to inference based on common naming conventions.
245    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 explicit type is given, use it
252        if let Some(t) = explicit_type {
253            return Ok(t);
254        }
255
256        // Try to resolve from registry
257        let registry = self.registry.read();
258        if let Some(param_def) = registry.get_param(resource_type, ref_param) {
259            // Check if it's a reference parameter
260            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            // Get targets
268            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                    // Fallback to inference
273                    return Ok(self.infer_target_type(ref_param));
274                } else {
275                    // Multiple targets - use inference for common patterns
276                    // This allows queries like `Observation?subject.name=Smith` to work
277                    // by defaulting `subject` to `Patient`
278                    return Ok(self.infer_target_type(ref_param));
279                }
280            }
281        }
282
283        // Fall back to inference based on common parameter names
284        Ok(self.infer_target_type(ref_param))
285    }
286
287    /// Infers target type based on common parameter naming conventions.
288    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                // Default: capitalize first letter
301                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    /// Resolves the type of the terminal parameter.
311    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            // Check for common parameters that might not be in registry
321            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    /// Builds SQL for a forward chain query.
336    ///
337    /// Generates nested subqueries that efficiently resolve the chain
338    /// using the search_index table.
339    ///
340    /// # Example Output
341    ///
342    /// For `Observation?subject.organization.name=Hospital`:
343    /// ```sql
344    /// r.id IN (
345    ///   SELECT si1.resource_id FROM search_index si1
346    ///   WHERE si1.tenant_id = ?1 AND si1.resource_type = 'Observation'
347    ///     AND si1.param_name = 'subject'
348    ///     AND si1.value_reference IN (
349    ///       SELECT 'Patient/' || si2.resource_id FROM search_index si2
350    ///       WHERE si2.tenant_id = ?1 AND si2.resource_type = 'Patient'
351    ///         AND si2.param_name = 'organization'
352    ///         AND si2.value_reference IN (
353    ///           SELECT 'Organization/' || si3.resource_id FROM search_index si3
354    ///           WHERE si3.tenant_id = ?1 AND si3.resource_type = 'Organization'
355    ///             AND si3.param_name = 'name'
356    ///             AND si3.value_string LIKE ?3
357    ///         )
358    ///     )
359    /// )
360    /// ```
361    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        // Build from innermost (terminal) to outermost
376        let param_num = self.param_offset + 1;
377
378        // Build terminal condition
379        let (terminal_sql, terminal_param) =
380            self.build_terminal_condition(chain, value, param_num)?;
381
382        // Get the last link to know the terminal resource type
383        let terminal_type = &chain.links[chain.links.len() - 1].target_type;
384
385        // Build the innermost query (terminal condition)
386        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            "", // Empty prefix since we concatenate with resource_id
392            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        // Wrap with each chain link from innermost to outermost
403        for (i, link) in chain.links.iter().enumerate().rev() {
404            let link_num = i + 1;
405            // current_type is the resource type that contains this reference param
406            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                // Outermost link: return just resource_id for r.id IN (...)
414                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                // Intermediate link: return '{type}/' || resource_id for value_reference matching
426                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        // Final wrap to select matching base resource IDs
440        let final_sql = format!("r.id IN ({})", current_sql);
441
442        Ok(SqlFragment::with_params(final_sql, vec![terminal_param]))
443    }
444
445    /// Builds the terminal condition for a chain query.
446    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                // Handle system|code format
465                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                // For date, use range comparison based on prefix
500                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                // Quantity comparison on value_quantity_value
509                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    /// Builds SQL for a reverse chain (_has) query.
526    ///
527    /// Generates subqueries that find base resources referenced by
528    /// resources matching the search criteria.
529    ///
530    /// # Example Output
531    ///
532    /// For `Patient?_has:Observation:subject:code=1234-5`:
533    /// ```sql
534    /// r.id IN (
535    ///   SELECT SUBSTR(si1.value_reference, INSTR(si1.value_reference, '/') + 1)
536    ///   FROM search_index si1
537    ///   WHERE si1.tenant_id = ?1 AND si1.resource_type = 'Observation'
538    ///     AND si1.param_name = 'subject'
539    ///     AND si1.value_reference LIKE 'Patient/%'
540    ///     AND si1.resource_id IN (
541    ///       SELECT si2.resource_id FROM search_index si2
542    ///       WHERE si2.tenant_id = ?1 AND si2.resource_type = 'Observation'
543    ///         AND si2.param_name = 'code'
544    ///         AND si2.value_token_code = ?3
545    ///     )
546    /// )
547    /// ```
548    pub fn build_reverse_chain_sql(
549        &self,
550        reverse_chain: &ReverseChainedParameter,
551    ) -> StorageResult<SqlFragment> {
552        // Check depth limit
553        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    /// Recursively builds reverse chain SQL.
576    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            // Terminal case: has a search parameter and value
586            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            // Build the search condition for the terminal parameter
593            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            // Build the reference extraction query
602            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            // Nested case: recurse into inner _has
626            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            // The inner chain's base type is this chain's source type
633            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            // Build the reference extraction query that wraps the inner query
645            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    /// Builds the terminal condition for a reverse chain search parameter.
663    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        // Determine the parameter type from the registry
672        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    /// Infers parameter type based on common parameter names.
750    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, // Default fallback
765        }
766    }
767}
768
769/// Builds a date comparison condition.
770fn 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            // Approximately equal: within a day for dates
784            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
797/// Builds a number comparison condition.
798fn build_number_condition(
799    column: &str,
800    value: &SearchValue,
801    param_num: usize,
802) -> (String, SqlParam) {
803    use crate::types::SearchPrefix;
804
805    // Try to parse as a number
806    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            // Approximately equal: within 10% for numbers
819            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        // Add some test parameters
843        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"); // 3 chain links
946        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}