Skip to main content

helios_persistence/types/
search_params.rs

1//! FHIR search parameter types.
2//!
3//! This module defines types for representing FHIR search parameters,
4//! including parameter types, modifiers, and prefixes. `SearchParamType`
5//! itself was lifted to `helios_fhir::search::SearchParamType` so
6//! `helios-sof` can use it without a circular dep; it is re-exported here
7//! for backwards-compat with persistence callers.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Serialize};
14
15pub use helios_fhir::search::SearchParamType;
16
17/// Search modifiers that can be applied to search parameters.
18///
19/// See: https://build.fhir.org/search.html#modifiers
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum SearchModifier {
23    /// Exact string match (string parameters).
24    Exact,
25    /// Contains substring (string parameters).
26    Contains,
27    /// Text search (token parameters).
28    Text,
29    /// Negation - exclude matches.
30    Not,
31    /// Match if value is missing.
32    Missing,
33    /// Match codes above in hierarchy (token parameters).
34    Above,
35    /// Match codes below in hierarchy (token parameters).
36    Below,
37    /// Match codes in a value set (token parameters).
38    In,
39    /// Match codes not in a value set (token parameters).
40    NotIn,
41    /// Match on identifier (reference parameters).
42    Identifier,
43    /// Specify reference type (reference parameters).
44    Type(String),
45    /// Match on type (token parameters for polymorphic elements).
46    OfType,
47    /// Iterate through results (_include modifier).
48    Iterate,
49    /// Advanced text search with synonyms and linguistic matching (FHIR v6.0.0).
50    ///
51    /// This modifier enables sophisticated text matching that may include:
52    /// - Synonym expansion
53    /// - Linguistic stemming
54    /// - Fuzzy matching
55    ///
56    /// Requires external terminology service integration.
57    TextAdvanced,
58    /// Match on code text/display value (token parameters, FHIR v6.0.0).
59    ///
60    /// Searches the text/display value of a CodeableConcept or Coding
61    /// rather than the code itself.
62    CodeText,
63}
64
65impl fmt::Display for SearchModifier {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            SearchModifier::Exact => write!(f, "exact"),
69            SearchModifier::Contains => write!(f, "contains"),
70            SearchModifier::Text => write!(f, "text"),
71            SearchModifier::Not => write!(f, "not"),
72            SearchModifier::Missing => write!(f, "missing"),
73            SearchModifier::Above => write!(f, "above"),
74            SearchModifier::Below => write!(f, "below"),
75            SearchModifier::In => write!(f, "in"),
76            SearchModifier::NotIn => write!(f, "not-in"),
77            SearchModifier::Identifier => write!(f, "identifier"),
78            SearchModifier::Type(t) => write!(f, "{}", t),
79            SearchModifier::OfType => write!(f, "ofType"),
80            SearchModifier::Iterate => write!(f, "iterate"),
81            SearchModifier::TextAdvanced => write!(f, "text-advanced"),
82            SearchModifier::CodeText => write!(f, "code-text"),
83        }
84    }
85}
86
87impl SearchModifier {
88    /// Parses a modifier string, returning None for unknown modifiers.
89    pub fn parse(s: &str) -> Option<Self> {
90        match s.to_lowercase().as_str() {
91            "exact" => Some(SearchModifier::Exact),
92            "contains" => Some(SearchModifier::Contains),
93            "text" => Some(SearchModifier::Text),
94            "not" => Some(SearchModifier::Not),
95            "missing" => Some(SearchModifier::Missing),
96            "above" => Some(SearchModifier::Above),
97            "below" => Some(SearchModifier::Below),
98            "in" => Some(SearchModifier::In),
99            "not-in" => Some(SearchModifier::NotIn),
100            "identifier" => Some(SearchModifier::Identifier),
101            // The FHIR spec (build.fhir.org) spells this `of-type`, and that is
102            // the form advertised in our CapabilityStatement; accept the legacy
103            // camelCase `ofType` too so older clients keep working.
104            "of-type" | "oftype" => Some(SearchModifier::OfType),
105            "iterate" => Some(SearchModifier::Iterate),
106            "text-advanced" => Some(SearchModifier::TextAdvanced),
107            "code-text" => Some(SearchModifier::CodeText),
108            _ => {
109                // Check if it's a resource type modifier
110                if s.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
111                    Some(SearchModifier::Type(s.to_string()))
112                } else {
113                    None
114                }
115            }
116        }
117    }
118
119    /// Returns true if this modifier is valid for the given parameter type.
120    pub fn is_valid_for(&self, param_type: SearchParamType) -> bool {
121        match self {
122            SearchModifier::Exact => param_type == SearchParamType::String,
123            // Per the FHIR spec, `:contains` is defined for string, reference,
124            // and uri parameters (substring/containment match).
125            SearchModifier::Contains => matches!(
126                param_type,
127                SearchParamType::String | SearchParamType::Reference | SearchParamType::Uri
128            ),
129            // Per the FHIR spec, `:text` is defined for string, token, and
130            // reference params (reference matches the indexed `Reference.display`).
131            SearchModifier::Text => matches!(
132                param_type,
133                SearchParamType::String | SearchParamType::Token | SearchParamType::Reference
134            ),
135            // Per the FHIR spec, `:not` is only defined for token parameters
136            // (it negates a code match). Our backends only implement it for
137            // token; advertising it for other types was incorrect.
138            SearchModifier::Not => param_type == SearchParamType::Token,
139            SearchModifier::Missing => true, // Valid for all types
140            // `:above`/`:below` are defined for token, uri, and reference.
141            // Reference uses URL/path-prefix hierarchy (canonical `|version`
142            // comparison is not implemented).
143            SearchModifier::Above | SearchModifier::Below => matches!(
144                param_type,
145                SearchParamType::Token | SearchParamType::Uri | SearchParamType::Reference
146            ),
147            // `:in`/`:not-in` are token-only per the FHIR spec. (`:not-in`
148            // itself returns 501 at the REST layer — negated value-set
149            // filtering is unimplemented.)
150            SearchModifier::In | SearchModifier::NotIn => param_type == SearchParamType::Token,
151            SearchModifier::Identifier | SearchModifier::Type(_) => {
152                param_type == SearchParamType::Reference
153            }
154            SearchModifier::OfType => param_type == SearchParamType::Token,
155            SearchModifier::Iterate => false, // Only for _include/_revinclude
156            // Per the FHIR spec (build.fhir.org), `:text-advanced` is defined for
157            // reference and token parameters (NOT string).
158            SearchModifier::TextAdvanced => matches!(
159                param_type,
160                SearchParamType::Token | SearchParamType::Reference
161            ),
162            // `:code-text` is defined for token and reference params (matches a
163            // code's display, or the indexed `Reference.display`).
164            SearchModifier::CodeText => matches!(
165                param_type,
166                SearchParamType::Token | SearchParamType::Reference
167            ),
168        }
169    }
170}
171
172/// Comparison prefixes for search parameters.
173///
174/// See: https://build.fhir.org/search.html#prefix
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum SearchPrefix {
178    /// Equal (default).
179    #[default]
180    Eq,
181    /// Not equal.
182    Ne,
183    /// Greater than.
184    Gt,
185    /// Less than.
186    Lt,
187    /// Greater than or equal.
188    Ge,
189    /// Less than or equal.
190    Le,
191    /// Starts after.
192    Sa,
193    /// Ends before.
194    Eb,
195    /// Approximately equal.
196    Ap,
197}
198
199impl fmt::Display for SearchPrefix {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            SearchPrefix::Eq => write!(f, "eq"),
203            SearchPrefix::Ne => write!(f, "ne"),
204            SearchPrefix::Gt => write!(f, "gt"),
205            SearchPrefix::Lt => write!(f, "lt"),
206            SearchPrefix::Ge => write!(f, "ge"),
207            SearchPrefix::Le => write!(f, "le"),
208            SearchPrefix::Sa => write!(f, "sa"),
209            SearchPrefix::Eb => write!(f, "eb"),
210            SearchPrefix::Ap => write!(f, "ap"),
211        }
212    }
213}
214
215impl FromStr for SearchPrefix {
216    type Err = String;
217
218    fn from_str(s: &str) -> Result<Self, Self::Err> {
219        match s.to_lowercase().as_str() {
220            "eq" => Ok(SearchPrefix::Eq),
221            "ne" => Ok(SearchPrefix::Ne),
222            "gt" => Ok(SearchPrefix::Gt),
223            "lt" => Ok(SearchPrefix::Lt),
224            "ge" => Ok(SearchPrefix::Ge),
225            "le" => Ok(SearchPrefix::Le),
226            "sa" => Ok(SearchPrefix::Sa),
227            "eb" => Ok(SearchPrefix::Eb),
228            "ap" => Ok(SearchPrefix::Ap),
229            _ => Err(format!("unknown search prefix: {}", s)),
230        }
231    }
232}
233
234impl SearchPrefix {
235    /// Extracts a prefix from the beginning of a value string.
236    ///
237    /// Returns the prefix and the remaining value.
238    pub fn extract(value: &str) -> (Self, &str) {
239        // `get(..2)` is char-boundary safe: it returns None when the first two
240        // bytes don't form a valid prefix (e.g. a multibyte first character like
241        // "Müller"), avoiding a panic from slicing mid-codepoint.
242        if let Some(prefix) = value.get(..2) {
243            if let Ok(p) = prefix.parse() {
244                return (p, &value[2..]);
245            }
246        }
247        (SearchPrefix::Eq, value)
248    }
249
250    /// Returns true if this prefix is valid for the given parameter type.
251    pub fn is_valid_for(&self, param_type: SearchParamType) -> bool {
252        match self {
253            SearchPrefix::Eq | SearchPrefix::Ne => true,
254            SearchPrefix::Gt | SearchPrefix::Lt | SearchPrefix::Ge | SearchPrefix::Le => {
255                matches!(
256                    param_type,
257                    SearchParamType::Number | SearchParamType::Date | SearchParamType::Quantity
258                )
259            }
260            // Per the FHIR spec, `sa`/`eb` (starts-after / ends-before) apply to
261            // date and quantity ordered types (not number).
262            SearchPrefix::Sa | SearchPrefix::Eb => matches!(
263                param_type,
264                SearchParamType::Date | SearchParamType::Quantity
265            ),
266            SearchPrefix::Ap => {
267                matches!(
268                    param_type,
269                    SearchParamType::Number | SearchParamType::Date | SearchParamType::Quantity
270                )
271            }
272        }
273    }
274}
275
276/// A parsed search parameter with its value.
277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
278pub struct SearchParameter {
279    /// The parameter name (e.g., "name", "identifier").
280    #[serde(default)]
281    pub name: String,
282
283    /// The parameter type.
284    #[serde(default)]
285    pub param_type: SearchParamType,
286
287    /// Modifier, if any.
288    #[serde(default)]
289    pub modifier: Option<SearchModifier>,
290
291    /// The search value(s). Multiple values are ORed.
292    #[serde(default)]
293    pub values: Vec<SearchValue>,
294
295    /// Chained parameters (e.g., patient.name=Smith).
296    #[serde(default)]
297    pub chain: Vec<ChainedParameter>,
298
299    /// Components for composite parameters.
300    /// Each component defines the type and expression for extracting component values.
301    #[serde(default)]
302    pub components: Vec<CompositeSearchComponent>,
303}
304
305/// Component definition for a composite search parameter.
306///
307/// Used when building composite search queries to define how each
308/// component of the composite value should be matched.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct CompositeSearchComponent {
311    /// The parameter type of this component (token, quantity, date, etc.).
312    pub param_type: SearchParamType,
313    /// The parameter name/code for this component.
314    pub param_name: String,
315}
316
317/// A single search value with optional prefix.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct SearchValue {
320    /// The comparison prefix.
321    pub prefix: SearchPrefix,
322
323    /// The value to search for.
324    pub value: String,
325}
326
327impl SearchValue {
328    /// Creates a new search value with the given prefix and value.
329    pub fn new(prefix: SearchPrefix, value: impl Into<String>) -> Self {
330        Self {
331            prefix,
332            value: value.into(),
333        }
334    }
335
336    /// Creates a search value with the default (eq) prefix.
337    pub fn eq(value: impl Into<String>) -> Self {
338        Self::new(SearchPrefix::Eq, value)
339    }
340
341    /// Parses a value string, extracting any prefix.
342    pub fn parse(s: &str) -> Self {
343        let (prefix, value) = SearchPrefix::extract(s);
344        Self::new(prefix, value)
345    }
346
347    /// Creates a token search value with optional system and code.
348    ///
349    /// Format: `[system]|[code]` or just `[code]`
350    pub fn token(system: Option<&str>, code: impl Into<String>) -> Self {
351        let code = code.into();
352        match system {
353            Some(sys) => Self::eq(format!("{}|{}", sys, code)),
354            None => Self::eq(code),
355        }
356    }
357
358    /// Creates a token search value with system only (no code).
359    ///
360    /// Format: `[system]|`
361    pub fn token_system_only(system: impl Into<String>) -> Self {
362        Self::eq(format!("{}|", system.into()))
363    }
364
365    /// Creates a boolean search value.
366    pub fn boolean(value: bool) -> Self {
367        Self::eq(value.to_string())
368    }
369
370    /// Creates a string search value (alias for eq).
371    pub fn string(value: impl Into<String>) -> Self {
372        Self::eq(value)
373    }
374
375    /// Creates a search value for :of-type modifier with three-part format.
376    ///
377    /// Format: `[type-system]|[type-code]|[value]`
378    ///
379    /// This is used with the :of-type modifier to search typed identifiers.
380    /// The format specifies the identifier type (system and code) followed
381    /// by the identifier value to match.
382    ///
383    /// # Example
384    ///
385    /// ```ignore
386    /// // Search for SSN identifier with value "123-45-6789"
387    /// SearchValue::of_type(
388    ///     "http://terminology.hl7.org/CodeSystem/v2-0203",
389    ///     "SS",
390    ///     "123-45-6789"
391    /// )
392    /// ```
393    pub fn of_type(
394        type_system: impl Into<String>,
395        type_code: impl Into<String>,
396        value: impl Into<String>,
397    ) -> Self {
398        Self::eq(format!(
399            "{}|{}|{}",
400            type_system.into(),
401            type_code.into(),
402            value.into()
403        ))
404    }
405}
406
407/// A chained search parameter.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ChainedParameter {
410    /// The reference parameter being chained through.
411    pub reference_param: String,
412
413    /// Optional type modifier on the reference.
414    pub target_type: Option<String>,
415
416    /// The target parameter on the referenced resource.
417    pub target_param: String,
418}
419
420/// A reverse chained parameter (_has).
421///
422/// Supports nested `_has` queries like:
423/// `Patient?_has:Observation:subject:_has:Provenance:target:agent=X`
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct ReverseChainedParameter {
426    /// The resource type that references this resource.
427    pub source_type: String,
428
429    /// The reference parameter on the source type.
430    pub reference_param: String,
431
432    /// The search parameter on the source type.
433    /// For nested `_has`, this may be empty or "_has" indicating nesting.
434    pub search_param: String,
435
436    /// The search value (None if this is a nested `_has` with further chaining).
437    pub value: Option<SearchValue>,
438
439    /// Nested reverse chain for multi-level `_has` queries.
440    pub nested: Option<Box<ReverseChainedParameter>>,
441}
442
443impl ReverseChainedParameter {
444    /// Creates a new terminal (non-nested) reverse chain parameter.
445    pub fn terminal(
446        source_type: impl Into<String>,
447        reference_param: impl Into<String>,
448        search_param: impl Into<String>,
449        value: SearchValue,
450    ) -> Self {
451        Self {
452            source_type: source_type.into(),
453            reference_param: reference_param.into(),
454            search_param: search_param.into(),
455            value: Some(value),
456            nested: None,
457        }
458    }
459
460    /// Creates a nested reverse chain parameter.
461    pub fn nested(
462        source_type: impl Into<String>,
463        reference_param: impl Into<String>,
464        inner: ReverseChainedParameter,
465    ) -> Self {
466        Self {
467            source_type: source_type.into(),
468            reference_param: reference_param.into(),
469            search_param: String::new(),
470            value: None,
471            nested: Some(Box::new(inner)),
472        }
473    }
474
475    /// Returns the depth of nesting (1 for non-nested, 2+ for nested).
476    pub fn depth(&self) -> usize {
477        match &self.nested {
478            Some(inner) => 1 + inner.depth(),
479            None => 1,
480        }
481    }
482
483    /// Returns true if this is a terminal (non-nested) reverse chain.
484    pub fn is_terminal(&self) -> bool {
485        self.nested.is_none()
486    }
487}
488
489/// Configuration for chain query limits.
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct ChainConfig {
492    /// Maximum depth for forward chained parameters.
493    /// Default: 4, Maximum: 8
494    pub max_forward_depth: usize,
495
496    /// Maximum depth for reverse chained parameters (_has).
497    /// Default: 4, Maximum: 8
498    pub max_reverse_depth: usize,
499}
500
501impl Default for ChainConfig {
502    fn default() -> Self {
503        Self {
504            max_forward_depth: 4,
505            max_reverse_depth: 4,
506        }
507    }
508}
509
510impl ChainConfig {
511    /// Creates a new chain configuration with specified depths.
512    pub fn new(max_forward_depth: usize, max_reverse_depth: usize) -> Self {
513        Self {
514            max_forward_depth: max_forward_depth.min(8),
515            max_reverse_depth: max_reverse_depth.min(8),
516        }
517    }
518
519    /// Validates that forward chain depth is within limits.
520    pub fn validate_forward_depth(&self, depth: usize) -> bool {
521        depth <= self.max_forward_depth
522    }
523
524    /// Validates that reverse chain depth is within limits.
525    pub fn validate_reverse_depth(&self, depth: usize) -> bool {
526        depth <= self.max_reverse_depth
527    }
528}
529
530/// Include directive for _include and _revinclude.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct IncludeDirective {
533    /// The type of include.
534    pub include_type: IncludeType,
535
536    /// The source resource type.
537    pub source_type: String,
538
539    /// The search parameter (reference) to follow.
540    pub search_param: String,
541
542    /// Optional target resource type filter.
543    pub target_type: Option<String>,
544
545    /// Whether to iterate (follow includes of included resources).
546    pub iterate: bool,
547}
548
549/// Type of include operation.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
551pub enum IncludeType {
552    /// Forward include (_include).
553    Include,
554    /// Reverse include (_revinclude).
555    Revinclude,
556}
557
558/// Sort direction for _sort parameter.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
560pub enum SortDirection {
561    /// Ascending order.
562    #[default]
563    Ascending,
564    /// Descending order.
565    Descending,
566}
567
568/// A sort directive.
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct SortDirective {
571    /// The parameter to sort by.
572    pub parameter: String,
573    /// The sort direction.
574    pub direction: SortDirection,
575    /// The resolved search-parameter type, when the sort is on an indexed
576    /// search parameter (rather than `_id` / `_lastUpdated`). Lets backends pick
577    /// the correct `search_index` value column. `None` for `_id`/`_lastUpdated`
578    /// or unresolved parameters.
579    #[serde(default)]
580    pub param_type: Option<SearchParamType>,
581}
582
583impl SortDirective {
584    /// Parses a sort parameter value (e.g., "-date" for descending).
585    pub fn parse(s: &str) -> Self {
586        if let Some(stripped) = s.strip_prefix('-') {
587            Self {
588                parameter: stripped.to_string(),
589                direction: SortDirection::Descending,
590                param_type: None,
591            }
592        } else {
593            Self {
594                parameter: s.to_string(),
595                direction: SortDirection::Ascending,
596                param_type: None,
597            }
598        }
599    }
600
601    /// Sets the resolved search-parameter type for this sort directive.
602    pub fn with_param_type(mut self, param_type: Option<SearchParamType>) -> Self {
603        self.param_type = param_type;
604        self
605    }
606}
607
608/// A complete search query with all parameters.
609#[derive(Debug, Clone, Default, Serialize, Deserialize)]
610pub struct SearchQuery {
611    /// The resource type being searched.
612    pub resource_type: String,
613
614    /// Standard search parameters.
615    pub parameters: Vec<SearchParameter>,
616
617    /// Reverse chain parameters (_has).
618    pub reverse_chains: Vec<ReverseChainedParameter>,
619
620    /// `_list` filters: logical ids of `List` resources whose `entry.item`
621    /// references restrict the result set. Multiple values are AND-ed (a result
622    /// must be a member of every listed `List`). Resolved application-side into
623    /// an `_id` filter by [`crate::search::list_resolver`], so any backend can
624    /// execute the rewritten query.
625    pub list: Vec<String>,
626
627    /// `_contained` mode: whether the search matches against resources nested in
628    /// other resources' `contained[]` arrays.
629    pub contained: ContainedMode,
630
631    /// `_containedType`: when a contained resource matches, whether to return the
632    /// container resource (default) or the contained resource itself.
633    pub contained_return: ContainedReturn,
634
635    /// Include directives.
636    pub includes: Vec<IncludeDirective>,
637
638    /// Sort directives.
639    pub sort: Vec<SortDirective>,
640
641    /// Result count limit (_count).
642    pub count: Option<u32>,
643
644    /// Offset for pagination.
645    pub offset: Option<u32>,
646
647    /// Cursor for keyset pagination.
648    pub cursor: Option<String>,
649
650    /// Whether to include total count (_total).
651    pub total: Option<TotalMode>,
652
653    /// Summary mode (_summary).
654    pub summary: Option<SummaryMode>,
655
656    /// Elements to include (_elements).
657    pub elements: Vec<String>,
658
659    /// Compartment membership filter, when this is a compartment search
660    /// (`GET /{compartmentType}/{id}/{targetType}`). Restricts results to
661    /// resources that reference the compartment via *any* of the membership
662    /// params (OR), per the FHIR CompartmentDefinition.
663    pub compartment: Option<CompartmentMembership>,
664
665    /// Raw query parameters for debugging.
666    pub raw_params: HashMap<String, Vec<String>>,
667}
668
669/// Compartment membership constraint for a compartment search.
670///
671/// A resource is in the compartment if it references `reference` through *any*
672/// of the `params` reference search parameters (logical OR). See
673/// https://hl7.org/fhir/compartmentdefinition.html.
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
675pub struct CompartmentMembership {
676    /// The membership reference search parameters (e.g. `patient`, `recorder`,
677    /// `asserter`). Matching ANY of these satisfies membership.
678    pub params: Vec<String>,
679    /// The compartment reference value, e.g. `Patient/123`.
680    pub reference: String,
681}
682
683/// Mode for the `_contained` parameter — whether contained resources (nested in
684/// another resource's `contained[]`) participate in matching.
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
686#[serde(rename_all = "lowercase")]
687pub enum ContainedMode {
688    /// `_contained=false` (default): match only top-level resources.
689    #[default]
690    Off,
691    /// `_contained=true`: match contained resources only.
692    On,
693    /// `_contained=both`: match both top-level and contained resources.
694    Both,
695}
696
697/// Mode for the `_containedType` parameter — what to return when a contained
698/// resource matches.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
700#[serde(rename_all = "lowercase")]
701pub enum ContainedReturn {
702    /// `_containedType=container` (default): return the container resource.
703    #[default]
704    Container,
705    /// `_containedType=contained`: return the contained resource itself.
706    Contained,
707}
708
709/// Mode for _total parameter.
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
711#[serde(rename_all = "lowercase")]
712pub enum TotalMode {
713    /// No total.
714    None,
715    /// Estimated total.
716    Estimate,
717    /// Accurate total.
718    Accurate,
719}
720
721/// Mode for _summary parameter.
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
723#[serde(rename_all = "lowercase")]
724pub enum SummaryMode {
725    /// Return summary elements only.
726    True,
727    /// Return full resource.
728    False,
729    /// Return text narrative only.
730    Text,
731    /// Return data elements only (no text).
732    Data,
733    /// Return count only.
734    Count,
735}
736
737/// Strips a `/_history/<vid>` version suffix from a FHIR reference, returning
738/// the version-agnostic base. References without a version are returned
739/// unchanged. Used so reference search matches regardless of version, per the
740/// FHIR spec.
741pub fn strip_reference_version(reference: &str) -> &str {
742    match reference.find("/_history/") {
743        Some(i) => &reference[..i],
744        None => reference,
745    }
746}
747
748impl SearchQuery {
749    /// Creates a new search query for the given resource type.
750    pub fn new(resource_type: impl Into<String>) -> Self {
751        Self {
752            resource_type: resource_type.into(),
753            ..Default::default()
754        }
755    }
756
757    /// Returns true if the client requested a total count via
758    /// `_total=accurate` or `_total=estimate`.
759    ///
760    /// `_total=none` or an unspecified `_total` returns `false`, so backends
761    /// skip the extra count query (FHIR allows omitting `Bundle.total`).
762    pub fn wants_total(&self) -> bool {
763        matches!(self.total, Some(TotalMode::Estimate | TotalMode::Accurate))
764    }
765
766    /// Adds a search parameter.
767    pub fn with_parameter(mut self, param: SearchParameter) -> Self {
768        self.parameters.push(param);
769        self
770    }
771
772    /// Adds an include directive.
773    pub fn with_include(mut self, include: IncludeDirective) -> Self {
774        self.includes.push(include);
775        self
776    }
777
778    /// Adds a sort directive.
779    pub fn with_sort(mut self, sort: SortDirective) -> Self {
780        self.sort.push(sort);
781        self
782    }
783
784    /// Sets the count limit.
785    pub fn with_count(mut self, count: u32) -> Self {
786        self.count = Some(count);
787        self
788    }
789
790    /// Sets the cursor for keyset pagination.
791    pub fn with_cursor(mut self, cursor: String) -> Self {
792        self.cursor = Some(cursor);
793        self
794    }
795
796    /// Returns true if this query uses any features that require special backend support.
797    pub fn requires_advanced_features(&self) -> bool {
798        // Chained parameters
799        if self.parameters.iter().any(|p| !p.chain.is_empty()) {
800            return true;
801        }
802
803        // Reverse chains
804        if !self.reverse_chains.is_empty() {
805            return true;
806        }
807
808        // Includes
809        if !self.includes.is_empty() {
810            return true;
811        }
812
813        // Terminology modifiers
814        if self.parameters.iter().any(|p| {
815            matches!(
816                p.modifier,
817                Some(SearchModifier::Above)
818                    | Some(SearchModifier::Below)
819                    | Some(SearchModifier::In)
820                    | Some(SearchModifier::NotIn)
821            )
822        }) {
823            return true;
824        }
825
826        false
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn test_search_param_type_display() {
836        assert_eq!(SearchParamType::String.to_string(), "string");
837        assert_eq!(SearchParamType::Token.to_string(), "token");
838        assert_eq!(SearchParamType::Reference.to_string(), "reference");
839    }
840
841    #[test]
842    fn test_search_param_type_parse() {
843        assert_eq!(
844            "string".parse::<SearchParamType>().unwrap(),
845            SearchParamType::String
846        );
847        assert_eq!(
848            "TOKEN".parse::<SearchParamType>().unwrap(),
849            SearchParamType::Token
850        );
851    }
852
853    #[test]
854    fn test_search_modifier_parse() {
855        assert_eq!(SearchModifier::parse("exact"), Some(SearchModifier::Exact));
856        assert_eq!(
857            SearchModifier::parse("contains"),
858            Some(SearchModifier::Contains)
859        );
860        assert_eq!(
861            SearchModifier::parse("Patient"),
862            Some(SearchModifier::Type("Patient".to_string()))
863        );
864        // Both the spec/CapabilityStatement spelling (`of-type`) and the legacy
865        // camelCase (`ofType`) must parse to the same modifier.
866        assert_eq!(
867            SearchModifier::parse("of-type"),
868            Some(SearchModifier::OfType)
869        );
870        assert_eq!(
871            SearchModifier::parse("ofType"),
872            Some(SearchModifier::OfType)
873        );
874        assert_eq!(SearchModifier::parse("unknown"), None);
875    }
876
877    #[test]
878    fn test_search_modifier_validity() {
879        assert!(SearchModifier::Exact.is_valid_for(SearchParamType::String));
880        assert!(!SearchModifier::Exact.is_valid_for(SearchParamType::Token));
881        // `:contains` is valid for string, reference, and uri (FHIR spec).
882        assert!(SearchModifier::Contains.is_valid_for(SearchParamType::String));
883        assert!(SearchModifier::Contains.is_valid_for(SearchParamType::Reference));
884        assert!(SearchModifier::Contains.is_valid_for(SearchParamType::Uri));
885        assert!(!SearchModifier::Contains.is_valid_for(SearchParamType::Token));
886        assert!(SearchModifier::Text.is_valid_for(SearchParamType::Token));
887        // `:text` is valid for string and token (FHIR spec).
888        assert!(SearchModifier::Text.is_valid_for(SearchParamType::String));
889        assert!(!SearchModifier::Text.is_valid_for(SearchParamType::Uri));
890        // `:not` is token-only per the FHIR spec.
891        assert!(SearchModifier::Not.is_valid_for(SearchParamType::Token));
892        assert!(!SearchModifier::Not.is_valid_for(SearchParamType::String));
893        // `:missing` is valid for every parameter type.
894        assert!(SearchModifier::Missing.is_valid_for(SearchParamType::String));
895        assert!(SearchModifier::Missing.is_valid_for(SearchParamType::Reference));
896        // `:in`/`:not-in` are token-only per the FHIR spec (not uri).
897        assert!(SearchModifier::In.is_valid_for(SearchParamType::Token));
898        assert!(!SearchModifier::In.is_valid_for(SearchParamType::Uri));
899        assert!(SearchModifier::NotIn.is_valid_for(SearchParamType::Token));
900        assert!(!SearchModifier::NotIn.is_valid_for(SearchParamType::Uri));
901        // `:above`/`:below` are valid for token, uri, and reference.
902        assert!(SearchModifier::Above.is_valid_for(SearchParamType::Uri));
903        assert!(SearchModifier::Below.is_valid_for(SearchParamType::Token));
904        assert!(SearchModifier::Above.is_valid_for(SearchParamType::Reference));
905        assert!(SearchModifier::Below.is_valid_for(SearchParamType::Reference));
906        assert!(!SearchModifier::Above.is_valid_for(SearchParamType::String));
907    }
908
909    #[test]
910    fn test_search_prefix_extract() {
911        assert_eq!(
912            SearchPrefix::extract("gt2020-01-01"),
913            (SearchPrefix::Gt, "2020-01-01")
914        );
915        assert_eq!(
916            SearchPrefix::extract("2020-01-01"),
917            (SearchPrefix::Eq, "2020-01-01")
918        );
919        assert_eq!(SearchPrefix::extract("le100"), (SearchPrefix::Le, "100"));
920    }
921
922    #[test]
923    fn test_search_prefix_validity() {
924        assert!(SearchPrefix::Gt.is_valid_for(SearchParamType::Number));
925        assert!(SearchPrefix::Gt.is_valid_for(SearchParamType::Date));
926        assert!(!SearchPrefix::Gt.is_valid_for(SearchParamType::String));
927        assert!(SearchPrefix::Sa.is_valid_for(SearchParamType::Date));
928        // `sa`/`eb` apply to date and quantity, but not number, per the spec.
929        assert!(SearchPrefix::Sa.is_valid_for(SearchParamType::Quantity));
930        assert!(SearchPrefix::Eb.is_valid_for(SearchParamType::Quantity));
931        assert!(!SearchPrefix::Sa.is_valid_for(SearchParamType::Number));
932    }
933
934    #[test]
935    fn test_search_value_parse() {
936        let value = SearchValue::parse("gt100");
937        assert_eq!(value.prefix, SearchPrefix::Gt);
938        assert_eq!(value.value, "100");
939
940        let value2 = SearchValue::parse("Smith");
941        assert_eq!(value2.prefix, SearchPrefix::Eq);
942        assert_eq!(value2.value, "Smith");
943    }
944
945    #[test]
946    fn test_sort_directive_parse() {
947        let asc = SortDirective::parse("date");
948        assert_eq!(asc.parameter, "date");
949        assert_eq!(asc.direction, SortDirection::Ascending);
950
951        let desc = SortDirective::parse("-date");
952        assert_eq!(desc.parameter, "date");
953        assert_eq!(desc.direction, SortDirection::Descending);
954    }
955
956    #[test]
957    fn test_search_query_builder() {
958        let query = SearchQuery::new("Patient")
959            .with_count(10)
960            .with_sort(SortDirective::parse("-_lastUpdated"));
961
962        assert_eq!(query.resource_type, "Patient");
963        assert_eq!(query.count, Some(10));
964        assert_eq!(query.sort.len(), 1);
965    }
966
967    #[test]
968    fn test_requires_advanced_features() {
969        let simple = SearchQuery::new("Patient");
970        assert!(!simple.requires_advanced_features());
971
972        let with_include = SearchQuery::new("Patient").with_include(IncludeDirective {
973            include_type: IncludeType::Include,
974            source_type: "Patient".to_string(),
975            search_param: "organization".to_string(),
976            target_type: None,
977            iterate: false,
978        });
979        assert!(with_include.requires_advanced_features());
980    }
981}