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
10use std::sync::Arc;
11
12use parking_lot::RwLock;
13
14use crate::error::{BackendError, StorageResult};
15use crate::search::SearchParameterRegistry;
16use crate::types::{ChainConfig, ReverseChainedParameter, SearchParamType, SearchValue};
17
18use super::query_builder::{SqlFragment, SqlParam};
19
20/// A single link in a forward chain.
21#[derive(Debug, Clone)]
22pub struct ChainLink {
23    /// The reference parameter being chained through.
24    pub reference_param: String,
25    /// The target resource type (resolved from registry or explicit modifier).
26    pub target_type: String,
27}
28
29/// A parsed forward chain with resolved types.
30#[derive(Debug, Clone)]
31pub struct ParsedChain {
32    /// The chain links from base to target.
33    pub links: Vec<ChainLink>,
34    /// The terminal parameter name to search on.
35    pub terminal_param: String,
36    /// The type of the terminal parameter.
37    pub terminal_type: SearchParamType,
38}
39
40/// Error types specific to chain parsing.
41#[derive(Debug, Clone)]
42pub enum ChainError {
43    /// Chain exceeds maximum allowed depth.
44    MaxDepthExceeded {
45        /// Depth of the chain that was rejected.
46        depth: usize,
47        /// Configured maximum forward-chain depth.
48        max: usize,
49    },
50    /// Reference parameter not found in registry.
51    UnknownReferenceParam {
52        /// Resource type the reference parameter was looked up against.
53        resource_type: String,
54        /// Reference parameter name.
55        param: String,
56    },
57    /// Cannot determine target type for reference.
58    AmbiguousTargetType {
59        /// Resource type the reference parameter belongs to.
60        resource_type: String,
61        /// Reference parameter name whose target is ambiguous.
62        param: String,
63    },
64    /// Terminal parameter not found.
65    UnknownTerminalParam {
66        /// Resource type the terminal parameter was looked up against.
67        resource_type: String,
68        /// Terminal parameter name.
69        param: String,
70    },
71    /// Chain is empty.
72    EmptyChain,
73    /// Invalid chain syntax.
74    InvalidSyntax {
75        /// Human-readable parser failure detail.
76        message: String,
77    },
78}
79
80impl std::fmt::Display for ChainError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            ChainError::MaxDepthExceeded { depth, max } => {
84                write!(
85                    f,
86                    "Chain depth {} exceeds maximum allowed depth {}",
87                    depth, max
88                )
89            }
90            ChainError::UnknownReferenceParam {
91                resource_type,
92                param,
93            } => {
94                write!(
95                    f,
96                    "Unknown reference parameter '{}' for resource type '{}'",
97                    param, resource_type
98                )
99            }
100            ChainError::AmbiguousTargetType {
101                resource_type,
102                param,
103            } => {
104                write!(
105                    f,
106                    "Ambiguous target type for parameter '{}' on '{}'. Use type modifier.",
107                    param, resource_type
108                )
109            }
110            ChainError::UnknownTerminalParam {
111                resource_type,
112                param,
113            } => {
114                write!(
115                    f,
116                    "Unknown terminal parameter '{}' for resource type '{}'",
117                    param, resource_type
118                )
119            }
120            ChainError::EmptyChain => write!(f, "Empty chain"),
121            ChainError::InvalidSyntax { message } => write!(f, "Invalid chain syntax: {}", message),
122        }
123    }
124}
125
126impl From<ChainError> for BackendError {
127    fn from(e: ChainError) -> Self {
128        BackendError::Internal {
129            backend_name: "sqlite".to_string(),
130            message: e.to_string(),
131            source: None,
132        }
133    }
134}
135
136/// Builder for chain SQL queries.
137///
138/// Uses the SearchParameterRegistry to resolve target types for reference
139/// parameters and generates efficient SQL subqueries.
140pub struct ChainQueryBuilder {
141    /// Tenant ID for the query.
142    tenant_id: String,
143    /// Base resource type being searched.
144    base_type: String,
145    /// Search parameter registry for type resolution.
146    registry: Arc<RwLock<SearchParameterRegistry>>,
147    /// Chain depth configuration.
148    config: ChainConfig,
149    /// Parameter offset for SQL placeholders.
150    param_offset: usize,
151}
152
153impl ChainQueryBuilder {
154    /// Creates a new chain query builder.
155    pub fn new(
156        tenant_id: impl Into<String>,
157        base_type: impl Into<String>,
158        registry: Arc<RwLock<SearchParameterRegistry>>,
159    ) -> Self {
160        Self {
161            tenant_id: tenant_id.into(),
162            base_type: base_type.into(),
163            registry,
164            config: ChainConfig::default(),
165            param_offset: 2, // Default: after ?1 (tenant) and ?2 (resource_type)
166        }
167    }
168
169    /// Sets the chain configuration.
170    pub fn with_config(mut self, config: ChainConfig) -> Self {
171        self.config = config;
172        self
173    }
174
175    /// Sets the parameter offset for SQL placeholders.
176    pub fn with_param_offset(mut self, offset: usize) -> Self {
177        self.param_offset = offset;
178        self
179    }
180
181    /// Parses a chain string into structured chain links.
182    ///
183    /// # Arguments
184    ///
185    /// * `chain_str` - The chain path (e.g., "subject.organization.name" or "subject:Patient.name")
186    ///
187    /// # Returns
188    ///
189    /// A `ParsedChain` with resolved types, or an error if parsing fails.
190    pub fn parse_chain(&self, chain_str: &str) -> Result<ParsedChain, ChainError> {
191        if chain_str.is_empty() {
192            return Err(ChainError::EmptyChain);
193        }
194
195        let parts: Vec<&str> = chain_str.split('.').collect();
196        if parts.len() < 2 {
197            return Err(ChainError::InvalidSyntax {
198                message: "Chain must have at least two parts (reference.param)".to_string(),
199            });
200        }
201
202        // Check depth limit
203        let chain_depth = parts.len() - 1; // Last part is terminal param, not a chain link
204        if !self.config.validate_forward_depth(chain_depth) {
205            return Err(ChainError::MaxDepthExceeded {
206                depth: chain_depth,
207                max: self.config.max_forward_depth,
208            });
209        }
210
211        let mut links = Vec::new();
212        let mut current_type = self.base_type.clone();
213
214        // Process all parts except the last (which is the terminal parameter)
215        for part in parts.iter().take(parts.len() - 1) {
216            let (ref_param, explicit_type) = self.parse_chain_part(part);
217
218            // Resolve the target type
219            let target_type = self.resolve_target_type(&current_type, &ref_param, explicit_type)?;
220
221            links.push(ChainLink {
222                reference_param: ref_param,
223                target_type: target_type.clone(),
224            });
225
226            current_type = target_type;
227        }
228
229        // Get the terminal parameter
230        let terminal_param = parts[parts.len() - 1].to_string();
231        let terminal_type = self.resolve_terminal_type(&current_type, &terminal_param)?;
232
233        Ok(ParsedChain {
234            links,
235            terminal_param,
236            terminal_type,
237        })
238    }
239
240    /// Parses a chain part, extracting type modifier if present.
241    ///
242    /// E.g., "subject:Patient" returns ("subject", Some("Patient"))
243    fn parse_chain_part(&self, part: &str) -> (String, Option<String>) {
244        if let Some((param, type_mod)) = part.split_once(':') {
245            (param.to_string(), Some(type_mod.to_string()))
246        } else {
247            (part.to_string(), None)
248        }
249    }
250
251    /// Resolves the target type for a reference parameter.
252    ///
253    /// Uses the registry to find the parameter definition and its targets.
254    /// If the parameter has multiple targets and no explicit type is given,
255    /// falls back to inference based on common naming conventions.
256    fn resolve_target_type(
257        &self,
258        resource_type: &str,
259        ref_param: &str,
260        explicit_type: Option<String>,
261    ) -> Result<String, ChainError> {
262        // If explicit type is given, use it
263        if let Some(t) = explicit_type {
264            return Ok(t);
265        }
266
267        // Try to resolve from registry
268        let registry = self.registry.read();
269        if let Some(param_def) = registry.get_param(resource_type, ref_param) {
270            // Check if it's a reference parameter
271            if param_def.param_type != SearchParamType::Reference {
272                return Err(ChainError::UnknownReferenceParam {
273                    resource_type: resource_type.to_string(),
274                    param: ref_param.to_string(),
275                });
276            }
277
278            // Get targets
279            if let Some(ref targets) = param_def.target {
280                if targets.len() == 1 {
281                    return Ok(targets[0].clone());
282                } else if targets.is_empty() {
283                    // Fallback to inference
284                    return Ok(crate::search::chain_resolver::infer_target_type(ref_param));
285                } else {
286                    // Multiple targets - use inference for common patterns
287                    // This allows queries like `Observation?subject.name=Smith` to work
288                    // by defaulting `subject` to `Patient`
289                    return Ok(crate::search::chain_resolver::infer_target_type(ref_param));
290                }
291            }
292        }
293
294        // Fall back to inference based on common parameter names
295        Ok(crate::search::chain_resolver::infer_target_type(ref_param))
296    }
297
298    /// Resolves the type of the terminal parameter.
299    fn resolve_terminal_type(
300        &self,
301        resource_type: &str,
302        param_name: &str,
303    ) -> Result<SearchParamType, ChainError> {
304        let registry = self.registry.read();
305        if let Some(param_def) = registry.get_param(resource_type, param_name) {
306            Ok(param_def.param_type)
307        } else {
308            // Check for common parameters that might not be in registry
309            match param_name {
310                "_id" | "id" => Ok(SearchParamType::Token),
311                "name" | "family" | "given" | "text" | "display" => Ok(SearchParamType::String),
312                "identifier" | "code" | "status" | "type" | "category" => {
313                    Ok(SearchParamType::Token)
314                }
315                _ => Err(ChainError::UnknownTerminalParam {
316                    resource_type: resource_type.to_string(),
317                    param: param_name.to_string(),
318                }),
319            }
320        }
321    }
322
323    /// Builds SQL for a forward chain query.
324    ///
325    /// Generates nested subqueries that efficiently resolve the chain
326    /// using the search_index table.
327    ///
328    /// # Example Output
329    ///
330    /// For `Observation?subject.organization.name=Hospital`:
331    /// ```sql
332    /// r.id IN (
333    ///   SELECT si1.resource_id FROM search_index si1
334    ///   WHERE si1.tenant_id = ?1 AND si1.resource_type = 'Observation'
335    ///     AND si1.param_name = 'subject'
336    ///     AND si1.value_reference IN (
337    ///       SELECT 'Patient/' || si2.resource_id FROM search_index si2
338    ///       WHERE si2.tenant_id = ?1 AND si2.resource_type = 'Patient'
339    ///         AND si2.param_name = 'organization'
340    ///         AND si2.value_reference IN (
341    ///           SELECT 'Organization/' || si3.resource_id FROM search_index si3
342    ///           WHERE si3.tenant_id = ?1 AND si3.resource_type = 'Organization'
343    ///             AND si3.param_name = 'name'
344    ///             AND si3.value_string LIKE ?3
345    ///         )
346    ///     )
347    /// )
348    /// ```
349    pub fn build_forward_chain_sql(
350        &self,
351        chain: &ParsedChain,
352        value: &SearchValue,
353    ) -> StorageResult<SqlFragment> {
354        if chain.links.is_empty() {
355            return Err(BackendError::Internal {
356                backend_name: "sqlite".to_string(),
357                message: "Empty chain".to_string(),
358                source: None,
359            }
360            .into());
361        }
362
363        // Build from innermost (terminal) to outermost
364        let param_num = self.param_offset + 1;
365
366        // Build terminal condition
367        let (terminal_sql, terminal_param) =
368            self.build_terminal_condition(chain, value, param_num)?;
369
370        // Get the last link to know the terminal resource type
371        let terminal_type = &chain.links[chain.links.len() - 1].target_type;
372
373        // Build the innermost query (terminal condition)
374        let mut current_sql = format!(
375            "SELECT '{}/{}' || si{}.resource_id FROM search_index si{} \
376             WHERE si{}.tenant_id = ?1 AND si{}.resource_type = '{}' \
377             AND si{}.param_name = '{}' AND {}",
378            terminal_type,
379            "", // Empty prefix since we concatenate with resource_id
380            chain.links.len(),
381            chain.links.len(),
382            chain.links.len(),
383            chain.links.len(),
384            terminal_type,
385            chain.links.len(),
386            chain.terminal_param,
387            terminal_sql
388        );
389
390        // Wrap with each chain link from innermost to outermost
391        for (i, link) in chain.links.iter().enumerate().rev() {
392            let link_num = i + 1;
393            // current_type is the resource type that contains this reference param
394            let current_type = if i == 0 {
395                &self.base_type
396            } else {
397                &chain.links[i - 1].target_type
398            };
399
400            if i == 0 {
401                // Outermost link: return just resource_id for r.id IN (...)
402                current_sql = format!(
403                    "SELECT si{link_num}.resource_id FROM search_index si{link_num} \
404                     WHERE si{link_num}.tenant_id = ?1 AND si{link_num}.resource_type = '{current_type}' \
405                     AND si{link_num}.param_name = '{ref_param}' \
406                     AND si{link_num}.value_reference IN ({inner})",
407                    link_num = link_num,
408                    current_type = current_type,
409                    ref_param = link.reference_param,
410                    inner = current_sql
411                );
412            } else {
413                // Intermediate link: return '{type}/' || resource_id for value_reference matching
414                current_sql = format!(
415                    "SELECT '{current_type}/' || 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                    current_type = current_type,
420                    link_num = link_num,
421                    ref_param = link.reference_param,
422                    inner = current_sql
423                );
424            }
425        }
426
427        // Final wrap to select matching base resource IDs
428        let final_sql = format!("r.id IN ({})", current_sql);
429
430        Ok(SqlFragment::with_params(final_sql, vec![terminal_param]))
431    }
432
433    /// Builds the terminal condition for a chain query.
434    fn build_terminal_condition(
435        &self,
436        chain: &ParsedChain,
437        value: &SearchValue,
438        param_num: usize,
439    ) -> StorageResult<(String, SqlParam)> {
440        let alias_num = chain.links.len();
441        let alias = format!("si{}", alias_num);
442
443        let (condition, param) = match chain.terminal_type {
444            SearchParamType::String => {
445                let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
446                (
447                    format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
448                    SqlParam::String(format!("%{}%", escaped)),
449                )
450            }
451            SearchParamType::Token => {
452                // Handle system|code format
453                if let Some((system, code)) = value.value.split_once('|') {
454                    if system.is_empty() {
455                        (
456                            format!(
457                                "({}.value_token_system IS NULL OR {}.value_token_system = '') \
458                                 AND {}.value_token_code = ?{}",
459                                alias, alias, alias, param_num
460                            ),
461                            SqlParam::String(code.to_string()),
462                        )
463                    } else {
464                        (
465                            format!(
466                                "{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
467                                alias,
468                                system.replace('\'', "''"),
469                                alias,
470                                param_num
471                            ),
472                            SqlParam::String(code.to_string()),
473                        )
474                    }
475                } else {
476                    (
477                        format!("{}.value_token_code = ?{}", alias, param_num),
478                        SqlParam::String(value.value.clone()),
479                    )
480                }
481            }
482            SearchParamType::Reference => (
483                format!("{}.value_reference LIKE ?{}", alias, param_num),
484                SqlParam::String(format!("%{}%", value.value)),
485            ),
486            SearchParamType::Date => {
487                // For date, use range comparison based on prefix
488                let date_col = format!("{}.value_date", alias);
489                build_date_condition(&date_col, value, param_num)
490            }
491            SearchParamType::Number => {
492                let num_col = format!("{}.value_number", alias);
493                build_number_condition(&num_col, value, param_num)
494            }
495            SearchParamType::Quantity => {
496                // Quantity comparison on value_quantity_value
497                let qty_col = format!("{}.value_quantity_value", alias);
498                build_number_condition(&qty_col, value, param_num)
499            }
500            SearchParamType::Uri => (
501                format!("{}.value_uri = ?{}", alias, param_num),
502                SqlParam::String(value.value.clone()),
503            ),
504            _ => (
505                format!("{}.value_string LIKE ?{}", alias, param_num),
506                SqlParam::String(format!("%{}%", value.value)),
507            ),
508        };
509
510        Ok((condition, param))
511    }
512
513    /// Builds SQL for a reverse chain (_has) query.
514    ///
515    /// Generates subqueries that find base resources referenced by
516    /// resources matching the search criteria.
517    ///
518    /// # Example Output
519    ///
520    /// For `Patient?_has:Observation:subject:code=1234-5`:
521    /// ```sql
522    /// r.id IN (
523    ///   SELECT SUBSTR(si1.value_reference, INSTR(si1.value_reference, '/') + 1)
524    ///   FROM search_index si1
525    ///   WHERE si1.tenant_id = ?1 AND si1.resource_type = 'Observation'
526    ///     AND si1.param_name = 'subject'
527    ///     AND si1.value_reference LIKE 'Patient/%'
528    ///     AND si1.resource_id IN (
529    ///       SELECT si2.resource_id FROM search_index si2
530    ///       WHERE si2.tenant_id = ?1 AND si2.resource_type = 'Observation'
531    ///         AND si2.param_name = 'code'
532    ///         AND si2.value_token_code = ?3
533    ///     )
534    /// )
535    /// ```
536    pub fn build_reverse_chain_sql(
537        &self,
538        reverse_chain: &ReverseChainedParameter,
539    ) -> StorageResult<SqlFragment> {
540        // Check depth limit
541        let depth = reverse_chain.depth();
542        if !self.config.validate_reverse_depth(depth) {
543            return Err(BackendError::Internal {
544                backend_name: "sqlite".to_string(),
545                message: format!(
546                    "Reverse chain depth {} exceeds maximum {}",
547                    depth, self.config.max_reverse_depth
548                ),
549                source: None,
550            }
551            .into());
552        }
553
554        let param_num = self.param_offset + 1;
555        let (sql, params) = self.build_reverse_chain_recursive(reverse_chain, 1, param_num)?;
556
557        Ok(SqlFragment::with_params(
558            format!("r.id IN ({})", sql),
559            params,
560        ))
561    }
562
563    /// Recursively builds reverse chain SQL.
564    fn build_reverse_chain_recursive(
565        &self,
566        rc: &ReverseChainedParameter,
567        depth: usize,
568        param_num: usize,
569    ) -> StorageResult<(String, Vec<SqlParam>)> {
570        let alias = format!("si{}", depth);
571
572        if rc.is_terminal() {
573            // Terminal case: has a search parameter and value
574            let value = rc.value.as_ref().ok_or_else(|| BackendError::Internal {
575                backend_name: "sqlite".to_string(),
576                message: "Terminal reverse chain must have a value".to_string(),
577                source: None,
578            })?;
579
580            // Build the search condition for the terminal parameter
581            let (search_condition, search_param) = self.build_reverse_terminal_condition(
582                &rc.source_type,
583                &rc.search_param,
584                value,
585                depth + 1,
586                param_num,
587            )?;
588
589            // Build the reference extraction query
590            let depth2 = depth + 1;
591            let sql = format!(
592                "SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
593                 FROM search_index {alias} \
594                 WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{src_type}' \
595                 AND {alias}.param_name = '{ref_param}' \
596                 AND {alias}.value_reference LIKE '{base_type}/%' \
597                 AND {alias}.resource_id IN (\
598                   SELECT si{depth2}.resource_id FROM search_index si{depth2} \
599                   WHERE si{depth2}.tenant_id = ?1 AND si{depth2}.resource_type = '{src_type}' \
600                   AND si{depth2}.param_name = '{search_param_name}' AND {search_condition}\
601                 )",
602                alias = alias,
603                src_type = rc.source_type,
604                ref_param = rc.reference_param,
605                base_type = self.base_type,
606                depth2 = depth2,
607                search_param_name = rc.search_param,
608                search_condition = search_condition,
609            );
610
611            Ok((sql, vec![search_param]))
612        } else {
613            // Nested case: recurse into inner _has
614            let inner = rc.nested.as_ref().ok_or_else(|| BackendError::Internal {
615                backend_name: "sqlite".to_string(),
616                message: "Non-terminal reverse chain must have nested chain".to_string(),
617                source: None,
618            })?;
619
620            // The inner chain's base type is this chain's source type
621            let inner_builder = ChainQueryBuilder::new(
622                &self.tenant_id,
623                &rc.source_type,
624                Arc::clone(&self.registry),
625            )
626            .with_config(self.config.clone())
627            .with_param_offset(param_num - 1);
628
629            let (inner_sql, inner_params) =
630                inner_builder.build_reverse_chain_recursive(inner, depth + 1, param_num)?;
631
632            // Build the reference extraction query that wraps the inner query
633            let sql = format!(
634                "SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
635                 FROM search_index {alias} \
636                 WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{}' \
637                 AND {alias}.param_name = '{}' \
638                 AND {alias}.value_reference LIKE '{}/%' \
639                 AND {alias}.resource_id IN ({inner_sql})",
640                rc.source_type,
641                rc.reference_param,
642                self.base_type,
643                alias = alias,
644            );
645
646            Ok((sql, inner_params))
647        }
648    }
649
650    /// Builds the terminal condition for a reverse chain search parameter.
651    fn build_reverse_terminal_condition(
652        &self,
653        resource_type: &str,
654        param_name: &str,
655        value: &SearchValue,
656        depth: usize,
657        param_num: usize,
658    ) -> StorageResult<(String, SqlParam)> {
659        // Determine the parameter type from the registry. Falls back to the
660        // shared value-shape heuristic for unregistered custom params.
661        let param_type = {
662            let registry = self.registry.read();
663            crate::search::resolve_param_type(
664                &registry,
665                resource_type,
666                param_name,
667                std::slice::from_ref(value),
668            )
669        };
670
671        let alias = format!("si{}", depth);
672
673        let (condition, param) = match param_type {
674            SearchParamType::String => {
675                let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
676                (
677                    format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
678                    SqlParam::String(format!("%{}%", escaped)),
679                )
680            }
681            SearchParamType::Token => {
682                if let Some((system, code)) = value.value.split_once('|') {
683                    if system.is_empty() {
684                        (
685                            format!(
686                                "({}.value_token_system IS NULL OR {}.value_token_system = '') \
687                                 AND {}.value_token_code = ?{}",
688                                alias, alias, alias, param_num
689                            ),
690                            SqlParam::String(code.to_string()),
691                        )
692                    } else {
693                        (
694                            format!(
695                                "{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
696                                alias,
697                                system.replace('\'', "''"),
698                                alias,
699                                param_num
700                            ),
701                            SqlParam::String(code.to_string()),
702                        )
703                    }
704                } else {
705                    (
706                        format!("{}.value_token_code = ?{}", alias, param_num),
707                        SqlParam::String(value.value.clone()),
708                    )
709                }
710            }
711            SearchParamType::Reference => (
712                format!("{}.value_reference LIKE ?{}", alias, param_num),
713                SqlParam::String(format!("%{}%", value.value)),
714            ),
715            SearchParamType::Date => {
716                let date_col = format!("{}.value_date", alias);
717                build_date_condition(&date_col, value, param_num)
718            }
719            SearchParamType::Number => {
720                let num_col = format!("{}.value_number", alias);
721                build_number_condition(&num_col, value, param_num)
722            }
723            SearchParamType::Quantity => {
724                let qty_col = format!("{}.value_quantity_value", alias);
725                build_number_condition(&qty_col, value, param_num)
726            }
727            SearchParamType::Uri => (
728                format!("{}.value_uri = ?{}", alias, param_num),
729                SqlParam::String(value.value.clone()),
730            ),
731            _ => (
732                format!("{}.value_string LIKE ?{}", alias, param_num),
733                SqlParam::String(format!("%{}%", value.value)),
734            ),
735        };
736
737        Ok((condition, param))
738    }
739}
740
741/// Builds a date comparison condition.
742fn build_date_condition(column: &str, value: &SearchValue, param_num: usize) -> (String, SqlParam) {
743    use crate::types::SearchPrefix;
744
745    let (op, val) = match value.prefix {
746        SearchPrefix::Eq => ("=", &value.value),
747        SearchPrefix::Ne => ("!=", &value.value),
748        SearchPrefix::Gt => (">", &value.value),
749        SearchPrefix::Lt => ("<", &value.value),
750        SearchPrefix::Ge => (">=", &value.value),
751        SearchPrefix::Le => ("<=", &value.value),
752        SearchPrefix::Sa => (">", &value.value),
753        SearchPrefix::Eb => ("<", &value.value),
754        SearchPrefix::Ap => {
755            // Approximately equal: within a day for dates
756            return (
757                format!("DATE({}) = DATE(?{})", column, param_num),
758                SqlParam::String(value.value.clone()),
759            );
760        }
761    };
762
763    (
764        format!("{} {} ?{}", column, op, param_num),
765        SqlParam::String(val.clone()),
766    )
767}
768
769/// Builds a number comparison condition.
770fn build_number_condition(
771    column: &str,
772    value: &SearchValue,
773    param_num: usize,
774) -> (String, SqlParam) {
775    use crate::types::SearchPrefix;
776
777    // Try to parse as a number
778    let num_value = value.value.parse::<f64>().unwrap_or(0.0);
779
780    let (op, val) = match value.prefix {
781        SearchPrefix::Eq => ("=", num_value),
782        SearchPrefix::Ne => ("!=", num_value),
783        SearchPrefix::Gt => (">", num_value),
784        SearchPrefix::Lt => ("<", num_value),
785        SearchPrefix::Ge => (">=", num_value),
786        SearchPrefix::Le => ("<=", num_value),
787        SearchPrefix::Sa => (">", num_value),
788        SearchPrefix::Eb => ("<", num_value),
789        SearchPrefix::Ap => {
790            // Approximately equal: within 10% for numbers
791            let lower = num_value * 0.9;
792            let upper = num_value * 1.1;
793            return (
794                format!("{} BETWEEN {} AND {}", column, lower, upper),
795                SqlParam::Float(num_value),
796            );
797        }
798    };
799
800    (
801        format!("{} {} ?{}", column, op, param_num),
802        SqlParam::Float(val),
803    )
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use crate::search::SearchParameterDefinition;
810
811    fn create_test_registry() -> Arc<RwLock<SearchParameterRegistry>> {
812        let mut registry = SearchParameterRegistry::new();
813
814        // Add some test parameters
815        let patient_subject = SearchParameterDefinition::new(
816            "http://hl7.org/fhir/SearchParameter/Observation-subject",
817            "subject",
818            SearchParamType::Reference,
819            "Observation.subject",
820        )
821        .with_base(vec!["Observation"])
822        .with_targets(vec!["Patient"]);
823
824        let patient_org = SearchParameterDefinition::new(
825            "http://hl7.org/fhir/SearchParameter/Patient-organization",
826            "organization",
827            SearchParamType::Reference,
828            "Patient.managingOrganization",
829        )
830        .with_base(vec!["Patient"])
831        .with_targets(vec!["Organization"]);
832
833        let org_name = SearchParameterDefinition::new(
834            "http://hl7.org/fhir/SearchParameter/Organization-name",
835            "name",
836            SearchParamType::String,
837            "Organization.name",
838        )
839        .with_base(vec!["Organization"]);
840
841        let patient_name = SearchParameterDefinition::new(
842            "http://hl7.org/fhir/SearchParameter/Patient-name",
843            "name",
844            SearchParamType::String,
845            "Patient.name",
846        )
847        .with_base(vec!["Patient"]);
848
849        let obs_code = SearchParameterDefinition::new(
850            "http://hl7.org/fhir/SearchParameter/Observation-code",
851            "code",
852            SearchParamType::Token,
853            "Observation.code",
854        )
855        .with_base(vec!["Observation"]);
856
857        registry.register(patient_subject).unwrap();
858        registry.register(patient_org).unwrap();
859        registry.register(org_name).unwrap();
860        registry.register(patient_name).unwrap();
861        registry.register(obs_code).unwrap();
862
863        Arc::new(RwLock::new(registry))
864    }
865
866    #[test]
867    fn test_parse_simple_chain() {
868        let registry = create_test_registry();
869        let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
870
871        let result = builder.parse_chain("subject.name");
872        assert!(result.is_ok());
873
874        let chain = result.unwrap();
875        assert_eq!(chain.links.len(), 1);
876        assert_eq!(chain.links[0].reference_param, "subject");
877        assert_eq!(chain.links[0].target_type, "Patient");
878        assert_eq!(chain.terminal_param, "name");
879        assert_eq!(chain.terminal_type, SearchParamType::String);
880    }
881
882    #[test]
883    fn test_parse_multi_level_chain() {
884        let registry = create_test_registry();
885        let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
886
887        let result = builder.parse_chain("subject.organization.name");
888        assert!(result.is_ok());
889
890        let chain = result.unwrap();
891        assert_eq!(chain.links.len(), 2);
892        assert_eq!(chain.links[0].reference_param, "subject");
893        assert_eq!(chain.links[0].target_type, "Patient");
894        assert_eq!(chain.links[1].reference_param, "organization");
895        assert_eq!(chain.links[1].target_type, "Organization");
896        assert_eq!(chain.terminal_param, "name");
897    }
898
899    #[test]
900    fn test_parse_chain_with_type_modifier() {
901        let registry = create_test_registry();
902        let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
903
904        let result = builder.parse_chain("subject:Patient.name");
905        assert!(result.is_ok());
906
907        let chain = result.unwrap();
908        assert_eq!(chain.links[0].target_type, "Patient");
909    }
910
911    #[test]
912    fn test_max_depth_exceeded() {
913        let registry = create_test_registry();
914        let builder = ChainQueryBuilder::new("tenant1", "Observation", registry)
915            .with_config(ChainConfig::new(2, 2));
916
917        let result = builder.parse_chain("a.b.c.d"); // 3 chain links
918        assert!(matches!(
919            result,
920            Err(ChainError::MaxDepthExceeded { depth: 3, max: 2 })
921        ));
922    }
923
924    #[test]
925    fn test_build_forward_chain_sql() {
926        let registry = create_test_registry();
927        let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
928
929        let chain = builder.parse_chain("subject.name").unwrap();
930        let value = SearchValue::eq("Smith");
931
932        let result = builder.build_forward_chain_sql(&chain, &value);
933        assert!(result.is_ok());
934
935        let fragment = result.unwrap();
936        assert!(fragment.sql.contains("r.id IN"));
937        assert!(fragment.sql.contains("search_index"));
938        assert!(fragment.sql.contains("subject"));
939        assert!(fragment.sql.contains("name"));
940    }
941
942    #[test]
943    fn test_build_reverse_chain_sql() {
944        let registry = create_test_registry();
945        let builder = ChainQueryBuilder::new("tenant1", "Patient", registry);
946
947        let rc = ReverseChainedParameter::terminal(
948            "Observation",
949            "subject",
950            "code",
951            SearchValue::eq("1234-5"),
952        );
953
954        let result = builder.build_reverse_chain_sql(&rc);
955        assert!(result.is_ok());
956
957        let fragment = result.unwrap();
958        assert!(fragment.sql.contains("r.id IN"));
959        assert!(fragment.sql.contains("Observation"));
960        assert!(fragment.sql.contains("subject"));
961        assert!(fragment.sql.contains("code"));
962        assert!(fragment.sql.contains("Patient/%"));
963    }
964
965    #[test]
966    fn test_reverse_chain_depth() {
967        let inner = ReverseChainedParameter::terminal(
968            "Provenance",
969            "target",
970            "agent",
971            SearchValue::eq("Practitioner/123"),
972        );
973        let outer = ReverseChainedParameter::nested("Observation", "subject", inner);
974
975        assert_eq!(outer.depth(), 2);
976        assert!(!outer.is_terminal());
977    }
978}