Skip to main content

helios_persistence/search/
extractor.rs

1//! SearchParameter Value Extractor.
2//!
3//! Uses FHIRPath expressions to extract searchable values from FHIR resources.
4
5use std::collections::HashMap;
6use std::sync::{Arc, OnceLock};
7
8use helios_fhirpath::EvaluationContext;
9use helios_fhirpath_support::EvaluationResult;
10use parking_lot::RwLock;
11use regex::Regex;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::types::SearchParamType;
17
18use super::converters::{IndexValue, ValueConverter};
19use super::errors::ExtractionError;
20use super::registry::{SearchParameterDefinition, SearchParameterRegistry};
21
22/// A value extracted from a resource for indexing.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ExtractedValue {
25    /// The parameter name (e.g., "name", "identifier").
26    pub param_name: String,
27
28    /// The parameter URL.
29    pub param_url: String,
30
31    /// The parameter type.
32    pub param_type: SearchParamType,
33
34    /// The extracted and converted value.
35    pub value: IndexValue,
36
37    /// Composite group ID (for composite parameters).
38    /// Values with the same group ID are part of the same composite match.
39    pub composite_group: Option<u32>,
40}
41
42impl ExtractedValue {
43    /// Creates a new extracted value.
44    pub fn new(
45        param_name: impl Into<String>,
46        param_url: impl Into<String>,
47        param_type: SearchParamType,
48        value: IndexValue,
49    ) -> Self {
50        Self {
51            param_name: param_name.into(),
52            param_url: param_url.into(),
53            param_type,
54            value,
55            composite_group: None,
56        }
57    }
58
59    /// Sets the composite group ID.
60    pub fn with_composite_group(mut self, group: u32) -> Self {
61        self.composite_group = Some(group);
62        self
63    }
64}
65
66/// Search values extracted from one `contained[]` entry of a container resource.
67#[derive(Debug, Clone)]
68pub struct ContainedExtraction {
69    /// The contained resource's `resourceType`.
70    pub contained_type: String,
71    /// The contained resource's local `id` (used for `Container/cid#localid`).
72    pub local_id: String,
73    /// The contained resource's JSON (the `contained[]` entry itself). Backends
74    /// that store content inline (Elasticsearch) index this directly.
75    pub content: Value,
76    /// The search values extracted from the contained resource.
77    pub values: Vec<ExtractedValue>,
78}
79
80/// Extracts searchable values from FHIR resources using FHIRPath.
81pub struct SearchParameterExtractor {
82    registry: Arc<RwLock<SearchParameterRegistry>>,
83}
84
85impl SearchParameterExtractor {
86    /// Creates a new extractor with the given registry.
87    pub fn new(registry: Arc<RwLock<SearchParameterRegistry>>) -> Self {
88        Self { registry }
89    }
90
91    /// Extracts all searchable values from a resource.
92    ///
93    /// Returns values for all active search parameters that apply to this resource type.
94    pub fn extract(
95        &self,
96        resource: &Value,
97        resource_type: &str,
98    ) -> Result<Vec<ExtractedValue>, ExtractionError> {
99        // Validate resource
100        let obj = resource
101            .as_object()
102            .ok_or_else(|| ExtractionError::InvalidResource {
103                message: "Resource must be a JSON object".to_string(),
104            })?;
105
106        // Verify resource type
107        if let Some(rt) = obj.get("resourceType").and_then(|v| v.as_str()) {
108            if rt != resource_type {
109                return Err(ExtractionError::InvalidResource {
110                    message: format!(
111                        "Resource type mismatch: expected {}, got {}",
112                        resource_type, rt
113                    ),
114                });
115            }
116        }
117
118        let mut results = Vec::new();
119
120        // Get active parameters for this resource type
121        let params = {
122            let registry = self.registry.read();
123            registry.get_active_params(resource_type)
124        };
125
126        for param in &params {
127            match self.extract_for_param(resource, param) {
128                Ok(values) => results.extend(values),
129                Err(e) => {
130                    // Log the error but continue with other parameters
131                    tracing::warn!(
132                        "Failed to extract values for parameter '{}': {}",
133                        param.code,
134                        e
135                    );
136                }
137            }
138        }
139
140        // Also extract common Resource-level parameters
141        let common_params = {
142            let registry = self.registry.read();
143            registry.get_active_params("Resource")
144        };
145
146        for param in &common_params {
147            if !params.iter().any(|p| p.code == param.code) {
148                match self.extract_for_param(resource, param) {
149                    Ok(values) => results.extend(values),
150                    Err(e) => {
151                        tracing::warn!(
152                            "Failed to extract values for common parameter '{}': {}",
153                            param.code,
154                            e
155                        );
156                    }
157                }
158            }
159        }
160
161        Ok(results)
162    }
163
164    /// Extracts searchable values from a container resource's `contained[]`
165    /// entries, for `_contained` search.
166    ///
167    /// Each contained resource is treated as a standalone resource of its own
168    /// `resourceType` and run through the normal [`Self::extract`] path. Contained
169    /// resources without a `resourceType` or `id` are skipped — an `id` is
170    /// required so the match can be addressed (`Container/cid#localid`) and the
171    /// container can return the specific contained resource.
172    pub fn extract_contained(&self, container: &Value) -> Vec<ContainedExtraction> {
173        let Some(entries) = container.get("contained").and_then(|c| c.as_array()) else {
174            return Vec::new();
175        };
176
177        let mut out = Vec::new();
178        for entry in entries {
179            let (Some(contained_type), Some(local_id)) = (
180                entry.get("resourceType").and_then(|v| v.as_str()),
181                entry.get("id").and_then(|v| v.as_str()),
182            ) else {
183                continue;
184            };
185            match self.extract(entry, contained_type) {
186                Ok(values) if !values.is_empty() => out.push(ContainedExtraction {
187                    contained_type: contained_type.to_string(),
188                    local_id: local_id.to_string(),
189                    content: entry.clone(),
190                    values,
191                }),
192                Ok(_) => {}
193                Err(e) => tracing::warn!(
194                    "Failed to extract contained {}/{}: {}",
195                    contained_type,
196                    local_id,
197                    e
198                ),
199            }
200        }
201        out
202    }
203
204    /// Extracts values for a specific parameter from a resource.
205    pub fn extract_for_param(
206        &self,
207        resource: &Value,
208        param: &SearchParameterDefinition,
209    ) -> Result<Vec<ExtractedValue>, ExtractionError> {
210        // Composite parameters are indexed component-by-component, with all the
211        // components of one composite instance sharing a `composite_group`.
212        if matches!(param.param_type, SearchParamType::Composite) {
213            return self.extract_composite(resource, param);
214        }
215
216        if param.expression.is_empty() {
217            return Ok(Vec::new());
218        }
219
220        // Get the resource type from the resource
221        let resource_type = resource
222            .get("resourceType")
223            .and_then(|v| v.as_str())
224            .unwrap_or("");
225
226        // Rewrite choice-type casts (`value as Quantity` → `valueQuantity`) so they
227        // resolve against schema-less JSON, then filter to this resource type.
228        let rewritten = rewrite_choice_types(&param.expression);
229        let filtered_expr = self.filter_expression_for_resource(&rewritten, resource_type);
230
231        if filtered_expr.is_empty() {
232            return Ok(Vec::new());
233        }
234
235        // Evaluate the filtered FHIRPath expression using the actual evaluator
236        let values = self.evaluate_fhirpath(resource, &filtered_expr)?;
237
238        let mut results = Vec::new();
239        for value in values {
240            let converted = ValueConverter::convert(&value, param.param_type, &param.code)?;
241            for idx_value in converted {
242                results.push(ExtractedValue::new(
243                    &param.code,
244                    &param.url,
245                    param.param_type,
246                    idx_value,
247                ));
248            }
249        }
250
251        Ok(results)
252    }
253
254    /// Extracts index rows for a composite search parameter.
255    ///
256    /// The composite's `expression` (e.g. `Observation` or
257    /// `Observation.component`) selects the base instances. Each instance gets a
258    /// `composite_group` id, and every component sub-expression is evaluated
259    /// relative to that instance and stored as its own row under the composite
260    /// parameter's code. Component value types are resolved from the registry by
261    /// the component `definition` URL.
262    fn extract_composite(
263        &self,
264        resource: &Value,
265        param: &SearchParameterDefinition,
266    ) -> Result<Vec<ExtractedValue>, ExtractionError> {
267        let components = match &param.component {
268            Some(c) if !c.is_empty() => c,
269            _ => return Ok(Vec::new()),
270        };
271
272        let resource_type = resource
273            .get("resourceType")
274            .and_then(|v| v.as_str())
275            .unwrap_or("");
276
277        let rewritten_base = rewrite_choice_types(&param.expression);
278        let base_expr = self.filter_expression_for_resource(&rewritten_base, resource_type);
279        if base_expr.is_empty() {
280            return Ok(Vec::new());
281        }
282
283        // Resolve each component's value type from the registry (by definition URL).
284        let component_types: Vec<Option<SearchParamType>> = {
285            let registry = self.registry.read();
286            components
287                .iter()
288                .map(|c| registry.get_by_url(&c.definition).map(|d| d.param_type))
289                .collect()
290        };
291
292        // Each base instance becomes a composite group.
293        let base_nodes = self.evaluate_fhirpath(resource, &base_expr)?;
294
295        let mut results = Vec::new();
296        for (group_idx, node) in base_nodes.iter().enumerate() {
297            let group = group_idx as u32;
298            for (component, sub_type) in components.iter().zip(component_types.iter()) {
299                let sub_type = match sub_type {
300                    Some(t) => *t,
301                    None => continue, // unknown component definition — skip
302                };
303                if component.expression.is_empty() {
304                    continue;
305                }
306                let comp_expr = rewrite_choice_types(&component.expression);
307                let values = self.evaluate_fhirpath(node, &comp_expr)?;
308                for value in values {
309                    let converted = ValueConverter::convert(&value, sub_type, &param.code)?;
310                    for idx_value in converted {
311                        results.push(
312                            ExtractedValue::new(&param.code, &param.url, sub_type, idx_value)
313                                .with_composite_group(group),
314                        );
315                    }
316                }
317            }
318        }
319
320        Ok(results)
321    }
322
323    /// Filters a FHIRPath expression to only include parts relevant to a specific resource type.
324    ///
325    /// Many FHIR SearchParameters have expressions that span multiple resource types, joined
326    /// with `|` (union). For example, the `patient` parameter has:
327    /// `AllergyIntolerance.patient | CarePlan.subject.where(resolve() is Patient) | ...`
328    ///
329    /// This method extracts only the parts that start with the given resource type and
330    /// simplifies common patterns that use `resolve()`.
331    fn filter_expression_for_resource(&self, expression: &str, resource_type: &str) -> String {
332        // Split by | and filter to parts starting with our resource type
333        let parts: Vec<String> = expression
334            .split('|')
335            .map(|p| p.trim())
336            .filter(|p| {
337                // Check if this part starts with our resource type
338                p.starts_with(resource_type)
339                    && (p.len() == resource_type.len()
340                        || p.chars().nth(resource_type.len()) == Some('.'))
341            })
342            .map(|p| self.simplify_resolve_pattern(p))
343            .collect();
344
345        if parts.is_empty() {
346            // If no parts match, return the original expression
347            // This handles expressions that don't use ResourceType prefix
348            expression.to_string()
349        } else {
350            // Join the filtered parts back with |
351            parts.join(" | ")
352        }
353    }
354
355    /// Simplifies common `.where(resolve() is ResourceType)` patterns.
356    ///
357    /// In FHIR SearchParameters, patterns like `subject.where(resolve() is Patient)`
358    /// are used to filter references by target type. Since we're extracting references
359    /// for indexing (not actually resolving them), we can safely strip this pattern
360    /// and just extract the reference value.
361    fn simplify_resolve_pattern(&self, expr: &str) -> String {
362        // Pattern: .where(resolve() is SomeType)
363        // We want to remove this suffix since we just need the reference value
364        if let Some(where_pos) = expr.find(".where(resolve()") {
365            // Find the matching closing paren
366            let after_where = &expr[where_pos..];
367            if after_where.rfind(')').is_some() {
368                // Return everything before .where(...)
369                return expr[..where_pos].to_string();
370            }
371        }
372        expr.to_string()
373    }
374
375    /// Evaluates a FHIRPath expression against a resource using the helios-fhirpath evaluator.
376    fn evaluate_fhirpath(
377        &self,
378        resource: &Value,
379        expression: &str,
380    ) -> Result<Vec<Value>, ExtractionError> {
381        // Convert JSON to EvaluationResult and set up context
382        let eval_result = json_to_evaluation_result(resource)?;
383
384        // Create evaluation context with the resource as 'this'
385        let mut context = EvaluationContext::new_empty_with_default_version();
386        context.set_this(eval_result);
387
388        // Evaluate the FHIRPath expression
389        let result = helios_fhirpath::evaluate_expression(expression, &context).map_err(|e| {
390            ExtractionError::FhirPathError {
391                expression: expression.to_string(),
392                message: e,
393            }
394        })?;
395
396        // Convert EvaluationResult back to JSON values
397        evaluation_result_to_json_values(&result)
398    }
399}
400
401/// Rewrites FHIRPath choice-type casts to concrete element names.
402///
403/// The extractor evaluates expressions against schema-less JSON, where a cast
404/// like `value as Quantity` cannot resolve `value` to the stored `valueQuantity`
405/// field. FHIR choice elements are serialized as `<element><Type>` (e.g.
406/// `valueQuantity`, `medicationCodeableConcept`, `occurrenceDateTime`), so we
407/// rewrite the three cast forms used in SearchParameter expressions to that
408/// concrete name:
409///
410/// - `(Observation.value as Quantity)` → `Observation.valueQuantity`
411/// - `value.as(Quantity)`              → `valueQuantity`
412/// - `Observation.value.ofType(Quantity)` → `Observation.valueQuantity`
413/// - `Observation.value as Quantity`   → `Observation.valueQuantity`
414///
415/// (The loader normalizes the `X as Type` form to `X.ofType(Type)`, so that
416/// form is what usually reaches the extractor.)
417///
418/// Stripping the parentheses in the `(... as Type)` form is intentional: it also
419/// lets `filter_expression_for_resource` recognize the `ResourceType.` prefix,
420/// which it otherwise drops for parenthesized union members.
421fn rewrite_choice_types(expression: &str) -> String {
422    static AS_FN: OnceLock<Regex> = OnceLock::new();
423    static OF_TYPE: OnceLock<Regex> = OnceLock::new();
424    static PAREN_AS: OnceLock<Regex> = OnceLock::new();
425    static BARE_AS: OnceLock<Regex> = OnceLock::new();
426
427    let path = r"[A-Za-z_][A-Za-z0-9_.]*";
428    let ty = r"[A-Za-z][A-Za-z0-9]*";
429    let as_fn =
430        AS_FN.get_or_init(|| Regex::new(&format!(r"({path})\.as\(\s*({ty})\s*\)")).unwrap());
431    let of_type =
432        OF_TYPE.get_or_init(|| Regex::new(&format!(r"({path})\.ofType\(\s*({ty})\s*\)")).unwrap());
433    let paren_as =
434        PAREN_AS.get_or_init(|| Regex::new(&format!(r"\(\s*({path})\s+as\s+({ty})\s*\)")).unwrap());
435    let bare_as = BARE_AS.get_or_init(|| Regex::new(&format!(r"({path})\s+as\s+({ty})")).unwrap());
436
437    let concrete = |caps: &regex::Captures| -> String {
438        let base = &caps[1];
439        let type_name = &caps[2];
440        let mut chars = type_name.chars();
441        let capitalized = match chars.next() {
442            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
443            None => String::new(),
444        };
445        format!("{}{}", base, capitalized)
446    };
447
448    // `.as(Type)` / `.ofType(Type)` and `(path as Type)` first (the latter also
449    // drops parens), then any remaining bare `path as Type`.
450    let step1 = as_fn.replace_all(expression, &concrete);
451    let step2 = of_type.replace_all(&step1, &concrete);
452    let step3 = paren_as.replace_all(&step2, &concrete);
453    bare_as.replace_all(&step3, &concrete).into_owned()
454}
455
456/// Converts a serde_json::Value to an EvaluationResult.
457fn json_to_evaluation_result(value: &Value) -> Result<EvaluationResult, ExtractionError> {
458    match value {
459        Value::Null => Ok(EvaluationResult::Empty),
460        Value::Bool(b) => Ok(EvaluationResult::boolean(*b)),
461        Value::Number(n) => {
462            if let Some(i) = n.as_i64() {
463                Ok(EvaluationResult::integer(i))
464            } else if let Some(f) = n.as_f64() {
465                Ok(EvaluationResult::decimal(Decimal::try_from(f).map_err(
466                    |e| ExtractionError::ConversionError {
467                        message: format!("Invalid decimal: {}", e),
468                    },
469                )?))
470            } else {
471                Err(ExtractionError::ConversionError {
472                    message: "Invalid number".to_string(),
473                })
474            }
475        }
476        Value::String(s) => Ok(EvaluationResult::string(s.clone())),
477        Value::Array(arr) => {
478            let results: Result<Vec<_>, _> = arr.iter().map(json_to_evaluation_result).collect();
479            Ok(EvaluationResult::collection(results?))
480        }
481        Value::Object(obj) => {
482            let mut map = HashMap::new();
483            for (key, val) in obj {
484                let eval_val = json_to_evaluation_result(val)?;
485                map.insert(key.clone(), eval_val);
486            }
487            Ok(EvaluationResult::Object {
488                map,
489                type_info: None,
490            })
491        }
492    }
493}
494
495/// Converts an EvaluationResult back to JSON values for the converter.
496fn evaluation_result_to_json_values(
497    result: &EvaluationResult,
498) -> Result<Vec<Value>, ExtractionError> {
499    match result {
500        EvaluationResult::Empty => Ok(Vec::new()),
501        EvaluationResult::Boolean(b, _, _) => Ok(vec![Value::Bool(*b)]),
502        EvaluationResult::String(s, _, _) => Ok(vec![Value::String(s.clone())]),
503        EvaluationResult::Integer(i, _, _) => Ok(vec![Value::Number((*i).into())]),
504        EvaluationResult::Integer64(i, _, _) => Ok(vec![Value::Number((*i).into())]),
505        EvaluationResult::Decimal(d, _, _) => {
506            // Convert decimal to JSON number
507            let f: f64 = (*d).try_into().unwrap_or(0.0);
508            Ok(vec![Value::Number(
509                serde_json::Number::from_f64(f).unwrap_or_else(|| serde_json::Number::from(0)),
510            )])
511        }
512        EvaluationResult::Date(s, _, _) => Ok(vec![Value::String(s.clone())]),
513        EvaluationResult::DateTime(s, _, _) => Ok(vec![Value::String(s.clone())]),
514        EvaluationResult::Time(s, _, _) => Ok(vec![Value::String(s.clone())]),
515        EvaluationResult::Quantity(value, unit, _, _) => {
516            // Convert Quantity to JSON object
517            let f: f64 = (*value).try_into().unwrap_or(0.0);
518            Ok(vec![serde_json::json!({
519                "value": f,
520                "unit": unit
521            })])
522        }
523        EvaluationResult::Collection { items, .. } => {
524            let mut values = Vec::new();
525            for item in items {
526                values.extend(evaluation_result_to_json_values(item)?);
527            }
528            Ok(values)
529        }
530        EvaluationResult::Object { map, .. } => {
531            // Convert object back to JSON
532            let mut obj = serde_json::Map::new();
533            for (key, val) in map {
534                let json_vals = evaluation_result_to_json_values(val)?;
535                // Check if the original value was a Collection - if so, preserve it as an array
536                // even if it has only one element, since FHIR arrays should stay as arrays
537                let is_collection = matches!(val, EvaluationResult::Collection { .. });
538                if is_collection {
539                    // Always preserve arrays as arrays
540                    obj.insert(key.clone(), Value::Array(json_vals));
541                } else if json_vals.len() == 1 {
542                    obj.insert(key.clone(), json_vals.into_iter().next().unwrap());
543                } else if !json_vals.is_empty() {
544                    obj.insert(key.clone(), Value::Array(json_vals));
545                }
546            }
547            Ok(vec![Value::Object(obj)])
548        }
549    }
550}
551
552impl std::fmt::Debug for SearchParameterExtractor {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        f.debug_struct("SearchParameterExtractor").finish()
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::search::loader::SearchParameterLoader;
562    use helios_fhir::FhirVersion;
563    use serde_json::json;
564    use std::path::PathBuf;
565
566    #[test]
567    fn rewrite_choice_types_handles_all_forms() {
568        // `.as(Type)` form.
569        assert_eq!(rewrite_choice_types("value.as(Quantity)"), "valueQuantity");
570        // `.ofType(Type)` form (what the loader normalizes `as` to).
571        assert_eq!(
572            rewrite_choice_types("(Observation.value.ofType(Quantity))"),
573            "(Observation.valueQuantity)"
574        );
575        // Parenthesized `as` form drops the parens.
576        assert_eq!(
577            rewrite_choice_types("(Observation.value as Quantity)"),
578            "Observation.valueQuantity"
579        );
580        // Lower-case primitive type names are capitalized.
581        assert_eq!(
582            rewrite_choice_types("(RiskAssessment.occurrence as dateTime)"),
583            "RiskAssessment.occurrenceDateTime"
584        );
585        // Unions are rewritten member-by-member; non-cast parts are untouched.
586        assert_eq!(
587            rewrite_choice_types("value.as(Quantity) | value.as(Range)"),
588            "valueQuantity | valueRange"
589        );
590        assert_eq!(rewrite_choice_types("Observation.code"), "Observation.code");
591    }
592
593    fn create_test_extractor() -> SearchParameterExtractor {
594        let loader = SearchParameterLoader::new(FhirVersion::R4);
595        let mut registry = SearchParameterRegistry::new();
596
597        // Load minimal fallback
598        if let Ok(params) = loader.load_embedded() {
599            for param in params {
600                let _ = registry.register(param);
601            }
602        }
603
604        // Load spec file for full parameter support
605        // CARGO_MANIFEST_DIR for this crate is crates/persistence
606        // We need to go up two levels to reach the workspace root
607        let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
608            .parent()
609            .and_then(|p| p.parent())
610            .map(|p| p.join("data"))
611            .unwrap_or_else(|| PathBuf::from("data"));
612
613        if let Ok(params) = loader.load_from_spec_file(&data_dir) {
614            for param in params {
615                let _ = registry.register(param);
616            }
617        }
618
619        SearchParameterExtractor::new(Arc::new(RwLock::new(registry)))
620    }
621
622    #[test]
623    fn test_extract_patient_name() {
624        let extractor = create_test_extractor();
625
626        let patient = json!({
627            "resourceType": "Patient",
628            "id": "123",
629            "name": [
630                {
631                    "family": "Smith",
632                    "given": ["John", "James"]
633                }
634            ]
635        });
636
637        let values = extractor.extract(&patient, "Patient").unwrap();
638
639        // Should have extracted name values
640        let name_values: Vec<_> = values.iter().filter(|v| v.param_name == "name").collect();
641        assert!(!name_values.is_empty(), "Should extract 'name' values");
642
643        // Should have extracted family
644        let family_values: Vec<_> = values.iter().filter(|v| v.param_name == "family").collect();
645        assert!(!family_values.is_empty(), "Should extract 'family' values");
646    }
647
648    #[test]
649    fn test_extract_patient_identifier() {
650        let extractor = create_test_extractor();
651
652        let patient = json!({
653            "resourceType": "Patient",
654            "id": "123",
655            "identifier": [
656                {
657                    "system": "http://hospital.org/mrn",
658                    "value": "12345"
659                }
660            ]
661        });
662
663        let values = extractor.extract(&patient, "Patient").unwrap();
664
665        let id_values: Vec<_> = values
666            .iter()
667            .filter(|v| v.param_name == "identifier")
668            .collect();
669        assert!(!id_values.is_empty(), "Should extract 'identifier' values");
670
671        if let IndexValue::Token { system, code, .. } = &id_values[0].value {
672            assert_eq!(system.as_ref().unwrap(), "http://hospital.org/mrn");
673            assert_eq!(code, "12345");
674        }
675    }
676
677    #[test]
678    fn test_extract_observation_values() {
679        let extractor = create_test_extractor();
680
681        let observation = json!({
682            "resourceType": "Observation",
683            "id": "obs1",
684            "code": {
685                "coding": [
686                    {
687                        "system": "http://loinc.org",
688                        "code": "8867-4"
689                    }
690                ]
691            },
692            "subject": {
693                "reference": "Patient/123"
694            },
695            "valueQuantity": {
696                "value": 120.5,
697                "unit": "mmHg"
698            }
699        });
700
701        let values = extractor.extract(&observation, "Observation").unwrap();
702
703        // Should have code
704        let code_values: Vec<_> = values.iter().filter(|v| v.param_name == "code").collect();
705        assert!(!code_values.is_empty(), "Should extract 'code' values");
706
707        // Should have subject
708        let subject_values: Vec<_> = values
709            .iter()
710            .filter(|v| v.param_name == "subject")
711            .collect();
712        assert!(
713            !subject_values.is_empty(),
714            "Should extract 'subject' values"
715        );
716    }
717
718    #[test]
719    fn test_invalid_resource() {
720        let extractor = create_test_extractor();
721
722        let not_object = json!("string");
723        let result = extractor.extract(&not_object, "Patient");
724        assert!(result.is_err());
725    }
726
727    #[test]
728    fn test_resource_type_mismatch() {
729        let extractor = create_test_extractor();
730
731        let patient = json!({
732            "resourceType": "Patient",
733            "id": "123"
734        });
735
736        let result = extractor.extract(&patient, "Observation");
737        assert!(result.is_err());
738    }
739
740    #[test]
741    fn test_fhirpath_with_where_clause() {
742        let extractor = create_test_extractor();
743
744        // Test a patient with multiple names - FHIRPath should be able to filter
745        let patient = json!({
746            "resourceType": "Patient",
747            "id": "123",
748            "name": [
749                {
750                    "use": "official",
751                    "family": "Smith",
752                    "given": ["John"]
753                },
754                {
755                    "use": "nickname",
756                    "given": ["Johnny"]
757                }
758            ]
759        });
760
761        let values = extractor.extract(&patient, "Patient").unwrap();
762
763        // Should extract all names (both official and nickname)
764        let name_values: Vec<_> = values.iter().filter(|v| v.param_name == "name").collect();
765        assert!(
766            name_values.len() >= 2,
767            "Should extract multiple name values"
768        );
769    }
770
771    #[test]
772    fn test_extract_observation_code_with_display() {
773        let extractor = create_test_extractor();
774
775        let observation = json!({
776            "resourceType": "Observation",
777            "id": "obs1",
778            "status": "final",
779            "code": {
780                "coding": [
781                    {
782                        "system": "http://loinc.org",
783                        "code": "8867-4",
784                        "display": "Heart rate"
785                    }
786                ]
787            }
788        });
789
790        // Extract values
791        let values = extractor.extract(&observation, "Observation").unwrap();
792
793        // Should have extracted code values
794        let code_values: Vec<_> = values.iter().filter(|v| v.param_name == "code").collect();
795        assert!(!code_values.is_empty(), "Should extract 'code' values");
796
797        // Check that display is populated
798        if let Some(first_code) = code_values.first() {
799            if let IndexValue::Token { display, .. } = &first_code.value {
800                assert_eq!(
801                    display.as_deref(),
802                    Some("Heart rate"),
803                    "Display should be populated"
804                );
805            }
806        }
807    }
808
809    #[test]
810    fn test_extract_resource_id() {
811        let extractor = create_test_extractor();
812
813        let patient = json!({
814            "resourceType": "Patient",
815            "id": "p1"
816        });
817
818        let values = extractor.extract(&patient, "Patient").unwrap();
819
820        // Should have extracted _id
821        let id_values: Vec<_> = values.iter().filter(|v| v.param_name == "_id").collect();
822        assert!(!id_values.is_empty(), "Should extract '_id' parameter");
823
824        // Check the value
825        if let Some(first_id) = id_values.first() {
826            if let IndexValue::Token { code, .. } = &first_id.value {
827                assert_eq!(code, "p1", "_id should be 'p1'");
828            }
829        }
830    }
831
832    #[test]
833    fn test_json_to_evaluation_result() {
834        // Test basic types
835        assert!(matches!(
836            json_to_evaluation_result(&json!(null)).unwrap(),
837            EvaluationResult::Empty
838        ));
839
840        assert!(matches!(
841            json_to_evaluation_result(&json!(true)).unwrap(),
842            EvaluationResult::Boolean(true, _, _)
843        ));
844
845        assert!(matches!(
846            json_to_evaluation_result(&json!("test")).unwrap(),
847            EvaluationResult::String(s, _, _) if s == "test"
848        ));
849
850        assert!(matches!(
851            json_to_evaluation_result(&json!(42)).unwrap(),
852            EvaluationResult::Integer(42, _, _)
853        ));
854
855        // Test array
856        if let EvaluationResult::Collection { items, .. } =
857            json_to_evaluation_result(&json!([1, 2, 3])).unwrap()
858        {
859            assert_eq!(items.len(), 3);
860        } else {
861            panic!("Expected collection");
862        }
863
864        // Test object
865        if let EvaluationResult::Object { map, .. } =
866            json_to_evaluation_result(&json!({"key": "value"})).unwrap()
867        {
868            assert!(map.contains_key("key"));
869        } else {
870            panic!("Expected object");
871        }
872    }
873
874    #[test]
875    fn test_filter_expression_for_resource() {
876        let extractor = create_test_extractor();
877
878        // Test multi-resource expression (like patient search param)
879        let complex_expr =
880            "AllergyIntolerance.patient | Immunization.patient | Observation.subject";
881        let filtered = extractor.filter_expression_for_resource(complex_expr, "Immunization");
882        assert_eq!(filtered, "Immunization.patient");
883
884        // Test with no matching parts - should return original
885        let no_match = extractor.filter_expression_for_resource(complex_expr, "Patient");
886        assert_eq!(no_match, complex_expr);
887
888        // Test simple expression (single resource type)
889        let simple_expr = "Patient.name";
890        let simple_filtered = extractor.filter_expression_for_resource(simple_expr, "Patient");
891        assert_eq!(simple_filtered, "Patient.name");
892
893        // Test that partial matches don't count (Observation shouldn't match Obs)
894        let partial = extractor.filter_expression_for_resource("Observation.code", "Obs");
895        assert_eq!(partial, "Observation.code");
896
897        // Test stripping .where(resolve() is X) pattern
898        let with_resolve = "Observation.subject.where(resolve() is Patient) | Patient.link.other";
899        let stripped = extractor.filter_expression_for_resource(with_resolve, "Observation");
900        assert_eq!(stripped, "Observation.subject");
901
902        // Test real-world patient search param pattern
903        let patient_expr = "CarePlan.subject.where(resolve() is Patient) | Observation.subject.where(resolve() is Patient)";
904        let careplan_filtered = extractor.filter_expression_for_resource(patient_expr, "CarePlan");
905        assert_eq!(careplan_filtered, "CarePlan.subject");
906        let obs_filtered = extractor.filter_expression_for_resource(patient_expr, "Observation");
907        assert_eq!(obs_filtered, "Observation.subject");
908    }
909
910    #[test]
911    fn test_extract_immunization_patient() {
912        let extractor = create_test_extractor();
913
914        let immunization = json!({
915            "resourceType": "Immunization",
916            "id": "test-imm",
917            "status": "completed",
918            "vaccineCode": {
919                "coding": [{
920                    "system": "http://hl7.org/fhir/sid/cvx",
921                    "code": "140"
922                }]
923            },
924            "patient": {
925                "reference": "Patient/test-patient"
926            },
927            "occurrenceDateTime": "2021-01-01"
928        });
929
930        let values = extractor.extract(&immunization, "Immunization").unwrap();
931
932        // Should have extracted patient reference
933        let patient_values: Vec<_> = values
934            .iter()
935            .filter(|v| v.param_name == "patient")
936            .collect();
937        assert!(
938            !patient_values.is_empty(),
939            "Should extract 'patient' values from Immunization"
940        );
941
942        // Check the reference value
943        if let IndexValue::Reference { reference, .. } = &patient_values[0].value {
944            assert!(
945                reference.contains("Patient/test-patient") || reference.contains("test-patient"),
946                "Should contain patient reference, got: {}",
947                reference
948            );
949        }
950    }
951}