Skip to main content

haste_codegen/
search_param_cardinality.rs

1//! Resolving a search parameter's `FHIRPath` expression against the
2//! `StructureDefinition` snapshots, to learn whether it can yield more than
3//! one value.
4//!
5//! A parameter that yields at most one value can be stored as a scalar column,
6//! which is what lets an index answer an ordered comparison, a prefix match or
7//! a sort. One that can yield more cannot.
8//!
9//! The answer is given per (parameter, resource type), because most shared
10//! base parameters are a union with one branch per type —
11//! `AllergyIntolerance.patient | … | Observation.subject.where(resolve() is
12//! Patient) | …` — and a resource only ever takes its own branch. Judging the
13//! union as a whole would let one repeating branch (`DocumentReference`'s
14//! encounter) deny a column to every other type.
15//!
16//! Each branch is reduced to a *plain dotted path* — `Patient.birthDate`,
17//! `Patient.name.family` — and resolved against the schema, so the answer is
18//! exact and needs no sample data. A `where()` filter is dropped from the path,
19//! because a filter can only remove values; an `ofType()` or `as` cast narrows
20//! the leaf to the cast's type. Anything else — an index accessor, a function
21//! the walk cannot see through — leaves the branch unanswered, and the
22//! parameter repeating for that type.
23//!
24//! Note what this deliberately does not answer. Cardinality is the count of
25//! *values the expression selects*, not the count of *index entries they
26//! become*: `Observation.code` selects one `CodeableConcept`, which fans out to
27//! one token per coding. Callers have to combine `repeats` with the leaf type's
28//! fan-out, which is why [`ResolvedPath::leaf_type`] is reported alongside.
29
30use std::collections::HashMap;
31use std::fmt::Write as _;
32
33use haste_fhir_model::r4::generated::{resources::StructureDefinition, types::ElementDefinition};
34
35use crate::utilities::extract::{self, Max};
36
37/// What walking a path against the snapshots established.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PathAnalysis {
40    Resolved(ResolvedPath),
41    /// A segment had no matching element. Reported rather than guessed at: a
42    /// path the walker cannot follow must not be assumed singular.
43    Unresolved {
44        /// The element path reached before the walk failed.
45        reached: String,
46        /// The segment that could not be found under it.
47        segment: String,
48    },
49    /// The expression is not a plain dotted path, so the schema alone cannot
50    /// answer it.
51    NotAPlainPath,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResolvedPath {
56    /// Whether any element along the path may occur more than once. True for
57    /// `Patient.name.family`, because `name` repeats even though `family`
58    /// does not.
59    pub repeats: bool,
60    /// Every FHIR type the element the path ends on may take: one for most
61    /// elements, several for a choice element (`Observation.effective[x]`).
62    pub leaf_types: Vec<String>,
63}
64
65/// The snapshots a path is resolved against, keyed by the type they define.
66pub struct SnapshotIndex<'a> {
67    by_type: HashMap<&'a str, &'a StructureDefinition>,
68}
69
70impl<'a> SnapshotIndex<'a> {
71    /// Indexes definitions by their `type`, ignoring any without a snapshot —
72    /// a differential alone cannot be walked.
73    #[must_use]
74    pub fn new(definitions: impl IntoIterator<Item = &'a StructureDefinition>) -> Self {
75        let mut by_type = HashMap::new();
76
77        for sd in definitions {
78            if sd.snapshot.is_none() {
79                continue;
80            }
81            if let Some(type_name) = sd.type_.value.as_deref() {
82                by_type.insert(type_name, sd);
83            }
84        }
85
86        Self { by_type }
87    }
88
89    fn elements(&self, type_name: &str) -> Option<&'a [ElementDefinition]> {
90        self.by_type
91            .get(type_name)?
92            .snapshot
93            .as_ref()
94            .map(|snapshot| snapshot.element.as_slice())
95    }
96
97    /// Finds the element at exactly `path` within the definition of the type
98    /// that path starts with.
99    fn element_at(&self, path: &str) -> Option<&'a ElementDefinition> {
100        let type_name = path.split('.').next()?;
101
102        self.elements(type_name)?
103            .iter()
104            .find(|element| element.path.value.as_deref() == Some(path))
105    }
106}
107
108/// Whether every character is one a plain dotted path can contain. Anything
109/// else — a call, a union, an index — means the schema alone cannot answer the
110/// question.
111fn is_plain_path(expression: &str) -> bool {
112    !expression.is_empty()
113        && expression
114            .chars()
115            .all(|c| c.is_ascii_alphanumeric() || c == '.')
116        && !expression.starts_with('.')
117        && !expression.ends_with('.')
118}
119
120fn repeats(element: &ElementDefinition) -> bool {
121    !matches!(extract::cardinality(element).1, Max::Fixed(1))
122}
123
124/// The single type an element declares, if it declares exactly one. A choice
125/// element declares several and a backbone declares `BackboneElement`, neither
126/// of which identifies where the walk continues on its own.
127fn sole_type(element: &ElementDefinition) -> Option<&str> {
128    match extract::field_types(element).as_slice() {
129        [single] => Some(single),
130        _ => None,
131    }
132}
133
134/// Walks a plain dotted path through the snapshots, segment by segment.
135///
136/// The walk crosses definitions: once a path leaves the resource's own
137/// elements — `Patient.name` is a `HumanName` — it continues in the definition
138/// of that type. `contentReference` is followed the same way, which is what
139/// lets a recursive structure like `Questionnaire.item.item` resolve.
140#[must_use]
141pub fn analyze_path(index: &SnapshotIndex, expression: &str) -> PathAnalysis {
142    if !is_plain_path(expression) {
143        return PathAnalysis::NotAPlainPath;
144    }
145
146    let mut segments = expression.split('.');
147
148    // The first segment names the type the walk starts in.
149    let Some(root) = segments.next() else {
150        return PathAnalysis::NotAPlainPath;
151    };
152
153    let Some(root_element) = index.element_at(root) else {
154        return PathAnalysis::Unresolved {
155            reached: String::new(),
156            segment: root.to_string(),
157        };
158    };
159
160    let mut current = root_element;
161    let mut current_path = root.to_string();
162    let mut saw_repeat = false;
163
164    for segment in segments {
165        let Some(next) = step(index, current, &current_path, segment) else {
166            return PathAnalysis::Unresolved {
167                reached: current_path,
168                segment: segment.to_string(),
169            };
170        };
171
172        saw_repeat |= repeats(next.element);
173        current = next.element;
174        current_path = next.path;
175    }
176
177    PathAnalysis::Resolved(ResolvedPath {
178        repeats: saw_repeat,
179        leaf_types: extract::field_types(current)
180            .into_iter()
181            .map(ToString::to_string)
182            .collect(),
183    })
184}
185
186struct Step<'a> {
187    element: &'a ElementDefinition,
188    path: String,
189}
190
191/// Resolves one segment below `current`, following into another definition or
192/// a content reference when the element is not defined inline.
193fn step<'a>(
194    index: &SnapshotIndex<'a>,
195    current: &'a ElementDefinition,
196    current_path: &str,
197    segment: &str,
198) -> Option<Step<'a>> {
199    // Defined inline, as a backbone element's children are.
200    let inline = format!("{current_path}.{segment}");
201    if let Some(element) = index.element_at(&inline) {
202        return Some(Step {
203            element,
204            path: inline,
205        });
206    }
207
208    // A choice element is written `value[x]` but named `value` in a path.
209    let choice = format!("{current_path}.{segment}[x]");
210    if let Some(element) = index.element_at(&choice) {
211        return Some(Step {
212            element,
213            path: choice,
214        });
215    }
216
217    // Recursive structures carry their children by reference rather than
218    // repeating them, so the walk continues wherever the reference points.
219    if let Some(target) = current
220        .contentReference
221        .as_ref()
222        .and_then(|r| r.value.as_deref())
223        .and_then(|r| r.strip_prefix('#'))
224    {
225        let referenced = format!("{target}.{segment}");
226        if let Some(element) = index.element_at(&referenced) {
227            return Some(Step {
228                element,
229                path: referenced,
230            });
231        }
232    }
233
234    // Otherwise the path has left this definition and continues in the one for
235    // the element's own type.
236    let type_name = sole_type(current)?;
237    let in_type = format!("{type_name}.{segment}");
238    index.element_at(&in_type).map(|element| Step {
239        element,
240        path: in_type,
241    })
242}
243
244/// Datatypes whose conversion to index values emits more than one entry for a
245/// single value, so a path that selects exactly one of them still produces
246/// several index entries.
247///
248/// This mirrors the per-type arms of `indexing_conversion` in
249/// `haste-fhir-search`: a `HumanName` becomes its text, family, every given,
250/// every prefix and every suffix; a `CodeableConcept` becomes one token per
251/// coding. Changing a converter to fan out — encoding an `Identifier` as both
252/// `system|value` and bare `value`, say — means adding its type here, or the
253/// generated table starts claiming parameters are singular when their values
254/// are being dropped.
255pub const FANNING_OUT_TYPES: [&str; 4] = ["HumanName", "Address", "CodeableConcept", "Timing"];
256
257/// One union branch reduced to what the schema can answer: the path it walks,
258/// and the type a cast narrows its leaf to.
259#[derive(Debug, Clone, PartialEq, Eq)]
260struct Branch {
261    path: String,
262    cast: Option<String>,
263}
264
265/// The resource type a branch starts from, read off its text so that even a
266/// branch that cannot be reduced is still known to apply, or not, to a type.
267fn branch_root(branch: &str) -> &str {
268    let branch = branch.trim_start_matches(|c: char| c == '(' || c.is_whitespace());
269    let end = branch
270        .find(|c: char| !c.is_ascii_alphanumeric())
271        .unwrap_or(branch.len());
272    &branch[..end]
273}
274
275/// Splits `expression` on the `|` operators at its top level, leaving any
276/// inside parentheses — a `where()` argument's — alone.
277fn split_union(expression: &str) -> Vec<&str> {
278    let mut branches = Vec::new();
279    let mut depth = 0usize;
280    let mut start = 0;
281
282    for (at, c) in expression.char_indices() {
283        match c {
284            '(' => depth += 1,
285            ')' => depth = depth.saturating_sub(1),
286            '|' if depth == 0 => {
287                branches.push(expression[start..at].trim());
288                start = at + 1;
289            }
290            _ => {}
291        }
292    }
293    branches.push(expression[start..].trim());
294    branches
295}
296
297/// Whether `text` is one parenthesised group from its first character to its
298/// last, so the outer pair can be dropped without changing its meaning.
299fn is_wrapped(text: &str) -> bool {
300    if !text.starts_with('(') || !text.ends_with(')') {
301        return false;
302    }
303    let mut depth = 0usize;
304    for (at, c) in text.char_indices() {
305        match c {
306            '(' => depth += 1,
307            ')' => {
308                depth -= 1;
309                if depth == 0 && at != text.len() - 1 {
310                    return false;
311                }
312            }
313            _ => {}
314        }
315    }
316    true
317}
318
319/// Removes every `.where(...)` from `path`. A filter keeps a subset of what it
320/// is given, so the path without it selects at least as many values — which
321/// makes the result a safe bound for the question asked of it.
322fn strip_where(path: &str) -> Option<String> {
323    let mut out = String::with_capacity(path.len());
324    let mut rest = path;
325
326    while let Some(at) = rest.find(".where(") {
327        out.push_str(&rest[..at]);
328        let mut depth = 0usize;
329        let mut close = None;
330        for (offset, c) in rest[at + ".where".len()..].char_indices() {
331            match c {
332                '(' => depth += 1,
333                ')' => {
334                    depth -= 1;
335                    if depth == 0 {
336                        close = Some(at + ".where".len() + offset);
337                        break;
338                    }
339                }
340                _ => {}
341            }
342        }
343        rest = &rest[close? + 1..];
344    }
345
346    out.push_str(rest);
347    Some(out)
348}
349
350/// Reduces one union branch to a plain path and an optional cast, or `None`
351/// when it uses anything the schema walk cannot see through.
352fn simplify_branch(branch: &str) -> Option<Branch> {
353    let mut text = branch.trim();
354    while is_wrapped(text) {
355        text = text[1..text.len() - 1].trim();
356    }
357
358    // `(Observation.value as Quantity)`
359    let (text, cast) = match text.split_once(" as ") {
360        Some((path, cast)) => (path.trim(), Some(cast.trim())),
361        None => (text, None),
362    };
363
364    // `RiskAssessment.occurrence.ofType(dateTime)`
365    let (text, cast) = match text
366        .strip_suffix(')')
367        .and_then(|t| t.rsplit_once(".ofType("))
368    {
369        Some((path, of_type)) if cast.is_none() => (path, Some(of_type)),
370        Some(_) => return None,
371        None => (text, cast),
372    };
373
374    let path = strip_where(text)?;
375    if !is_plain_path(&path) || cast.is_some_and(|cast| !is_plain_path(cast)) {
376        return None;
377    }
378
379    Some(Branch {
380        path,
381        cast: cast.map(ToString::to_string),
382    })
383}
384
385/// Bases whose parameters apply to every resource type.
386const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
387
388/// Whether a parameter produces at most one index entry for a resource of
389/// type `base`.
390///
391/// Only the branches a `base` resource can take count: those rooted at it,
392/// and at the universal bases. Every one of them has to reduce to the same
393/// path — several branches over one choice element, each casting it to a
394/// different type, still select at most one value, because the element holds
395/// one type at a time — and that path must not repeat.
396///
397/// And the value has to convert to at most one index entry, for *every* type
398/// it may take: a choice element that can hold a `Timing` fans out whenever
399/// it does.
400///
401/// Anything the walker could not resolve, or that needs the engine, is not
402/// single — the cost of being wrong that way is slower storage, and the cost
403/// of being wrong the other way is silently dropping values.
404#[must_use]
405pub fn is_single_valued_for(index: &SnapshotIndex, expression: &str, base: &str) -> bool {
406    let mut applicable = Vec::new();
407
408    for branch in split_union(expression) {
409        let root = branch_root(branch);
410        if root != base && !UNIVERSAL_BASES.contains(&root) {
411            continue;
412        }
413
414        let Some(simplified) = simplify_branch(branch) else {
415            return false;
416        };
417        applicable.push(simplified);
418    }
419
420    let Some(first) = applicable.first() else {
421        return false;
422    };
423    if applicable.iter().any(|branch| branch.path != first.path) {
424        return false;
425    }
426
427    let PathAnalysis::Resolved(resolved) = analyze_path(index, &first.path) else {
428        return false;
429    };
430    if resolved.repeats {
431        return false;
432    }
433
434    // A cast narrows the leaf to its type; an uncast branch can yield any
435    // type the element declares.
436    let mut leaf_types: Vec<&str> = Vec::new();
437    for branch in &applicable {
438        match &branch.cast {
439            Some(cast) => leaf_types.push(cast),
440            None => leaf_types.extend(resolved.leaf_types.iter().map(String::as_str)),
441        }
442    }
443
444    !leaf_types.is_empty()
445        && !leaf_types
446            .iter()
447            .any(|leaf| FANNING_OUT_TYPES.contains(leaf))
448}
449
450/// Reads every `StructureDefinition` under the given files or directories,
451/// following the same JSON-file walk the other generators use. Bundles are
452/// unwrapped, so a `profiles-resources.min.json` can be passed directly.
453///
454/// # Errors
455///
456/// Returns an error if a path cannot be read or a file is not valid JSON.
457pub fn load_definitions(paths: &[String]) -> Result<Vec<StructureDefinition>, String> {
458    load_resources(paths, |resource| match resource {
459        haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(sd) => Some(sd),
460        _ => None,
461    })
462}
463
464/// Reads every `SearchParameter` under the given files or directories.
465///
466/// # Errors
467///
468/// Returns an error if a path cannot be read or a file is not valid JSON.
469pub fn load_search_parameters(
470    paths: &[String],
471) -> Result<Vec<haste_fhir_model::r4::generated::resources::SearchParameter>, String> {
472    load_resources(paths, |resource| match resource {
473        haste_fhir_model::r4::generated::resources::Resource::SearchParameter(sp) => Some(sp),
474        _ => None,
475    })
476}
477
478fn load_resources<T>(
479    paths: &[String],
480    pick: impl Fn(haste_fhir_model::r4::generated::resources::Resource) -> Option<T> + Copy,
481) -> Result<Vec<T>, String> {
482    use haste_fhir_model::r4::generated::resources::Resource;
483
484    let mut collected = Vec::new();
485
486    for path in paths {
487        for entry in walkdir::WalkDir::new(path)
488            .sort_by_file_name()
489            .into_iter()
490            .filter_map(Result::ok)
491            .filter(|e| e.metadata().is_ok_and(|m| m.is_file()))
492            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
493        {
494            let contents = std::fs::read_to_string(entry.path())
495                .map_err(|e| format!("{}: {e}", entry.path().display()))?;
496
497            let resource: Resource = serde_json::from_str(&contents)
498                .map_err(|e| format!("{}: {e}", entry.path().display()))?;
499
500            match resource {
501                // A bundle of definitions, as the HL7 packages ship them.
502                Resource::Bundle(bundle) => {
503                    collected.extend(
504                        bundle
505                            .entry
506                            .unwrap_or_default()
507                            .into_iter()
508                            .filter_map(|e| e.resource)
509                            .filter_map(|r| pick(*r)),
510                    );
511                }
512                resource => collected.extend(pick(resource)),
513            }
514        }
515    }
516
517    Ok(collected)
518}
519
520/// Emits the Rust source for the compiled lookup table.
521///
522/// The table is the sorted set of canonical URLs whose parameters are single
523/// valued, so a lookup is a binary search over static data with no
524/// initialisation. Absence means "not known to be single", which is the answer
525/// a caller should act on anyway for a URL it has never heard of.
526#[must_use]
527pub fn generate_lookup(
528    definitions: &[StructureDefinition],
529    search_parameters: &[haste_fhir_model::r4::generated::resources::SearchParameter],
530) -> String {
531    let index = SnapshotIndex::new(definitions.iter());
532    let index = &index;
533
534    let mut pairs: Vec<(&str, &str)> = search_parameters
535        .iter()
536        .flat_map(|parameter| {
537            let url = parameter.url.value.as_deref();
538            let expression = parameter
539                .expression
540                .as_ref()
541                .and_then(|e| e.value.as_deref());
542
543            parameter.base.iter().filter_map(move |base| {
544                let (url, expression, base) = (url?, expression?, base.as_str()?);
545                is_single_valued_for(index, expression, base).then_some((url, base))
546            })
547        })
548        .collect();
549
550    pairs.sort_unstable();
551    pairs.dedup();
552
553    let entries = pairs
554        .iter()
555        .fold(String::new(), |mut entries, (url, base)| {
556            let _ = writeln!(entries, "    ({url:?}, {base:?}),");
557            entries
558        });
559
560    format!(
561        r#"//! Search parameters that produce at most one index value per resource.
562//!
563//! @generated by `bash scripts/search_param_cardinality_build.sh` — do not edit.
564//!
565//! A parameter listed here for a resource type selects at most one value from
566//! a resource of that type, and converts it to at most one index entry, so it
567//! can be stored as a scalar column, which is what lets an index answer an
568//! ordered comparison, a prefix match or a sort. The answer is per type:
569//! a shared parameter like `clinical-patient` takes one branch of its union
570//! per type, and can be single for some and repeating for others.
571//!
572//! Absence means "not known to be single". A parameter whose expression needs
573//! the `FHIRPath` engine to resolve, or that the schema walk could not follow,
574//! is absent for the same reason a genuinely repeating one is: storing several
575//! values in a scalar column keeps the first and drops the rest.
576
577/// (canonical URL, base) pairs of the single-valued parameters, sorted for
578/// binary search.
579static SINGLE_VALUED: [(&str, &str); {count}] = [
580{entries}];
581
582/// Whether the parameter `url` produces at most one index value for a
583/// resource of type `resource_type` — as a parameter of that type, or of
584/// every type (`Resource`, `DomainResource`).
585///
586/// Unknown pairs answer `false`, which is the safe direction: a caller that
587/// treats an unclassified parameter as multi valued is slower, one that treats
588/// it as single loses data.
589#[must_use]
590pub fn is_single_valued(url: &str, resource_type: &str) -> bool {{
591    let listed = |base: &str| {{
592        SINGLE_VALUED
593            .binary_search_by(|(u, b)| (*u, *b).cmp(&(url, base)))
594            .is_ok()
595    }};
596
597    listed(resource_type) || listed("Resource") || listed("DomainResource")
598}}
599"#,
600        count = pairs.len(),
601        entries = entries,
602    )
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use haste_fhir_model::r4::generated::resources::{Bundle, Resource, SearchParameter};
609    use std::sync::LazyLock;
610
611    fn definitions_from(json: &str) -> Vec<StructureDefinition> {
612        serde_json::from_str::<Bundle>(json)
613            .expect("bundle parses")
614            .entry
615            .unwrap_or_default()
616            .into_iter()
617            .filter_map(|e| e.resource)
618            .filter_map(|r| match *r {
619                Resource::StructureDefinition(sd) => Some(sd),
620                _ => None,
621            })
622            .collect()
623    }
624
625    static DEFINITIONS: LazyLock<Vec<StructureDefinition>> = LazyLock::new(|| {
626        let mut all = definitions_from(include_str!(
627            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-resources.min.json"
628        ));
629        all.extend(definitions_from(include_str!(
630            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-types.min.json"
631        )));
632        all
633    });
634
635    static SEARCH_PARAMETERS: LazyLock<Vec<SearchParameter>> = LazyLock::new(|| {
636        serde_json::from_str::<Bundle>(include_str!(
637            "../../../../artifacts/r4/hl7-core/definitions/hl7/search-parameters.min.json"
638        ))
639        .expect("bundle parses")
640        .entry
641        .unwrap_or_default()
642        .into_iter()
643        .filter_map(|e| e.resource)
644        .filter_map(|r| match *r {
645            Resource::SearchParameter(sp) => Some(sp),
646            _ => None,
647        })
648        .collect()
649    });
650
651    /// The single (parameter, type) pairs in the HL7 base corpus. Below this,
652    /// something stopped resolving.
653    const SINGLE_PAIRS_FLOOR: usize = 780;
654
655    fn index() -> SnapshotIndex<'static> {
656        SnapshotIndex::new(DEFINITIONS.iter())
657    }
658
659    fn resolved(expression: &str) -> ResolvedPath {
660        match analyze_path(&index(), expression) {
661            PathAnalysis::Resolved(resolved) => resolved,
662            other => panic!("{expression} did not resolve: {other:?}"),
663        }
664    }
665
666    fn types(resolved: &ResolvedPath) -> Vec<&str> {
667        resolved.leaf_types.iter().map(String::as_str).collect()
668    }
669
670    #[test]
671    fn a_singular_element_does_not_repeat() {
672        let birth_date = resolved("Patient.birthDate");
673
674        assert!(!birth_date.repeats);
675        assert_eq!(types(&birth_date), ["date"]);
676    }
677
678    /// `family` is `0..1`, but `name` above it is `0..*`, so the path repeats.
679    #[test]
680    fn a_repeating_element_anywhere_on_the_path_repeats() {
681        assert!(resolved("Patient.name.family").repeats);
682        assert!(resolved("Patient.name").repeats);
683    }
684
685    /// The walk crosses into another definition when the path leaves the
686    /// resource's own elements: `name` is a `HumanName`, `family` lives there.
687    #[test]
688    fn the_walk_crosses_into_complex_types() {
689        assert_eq!(types(&resolved("Patient.name.family")), ["string"]);
690        // Two crossings: Patient.contact is a backbone, its name a HumanName.
691        assert!(resolved("Patient.contact.name.family").repeats);
692    }
693
694    /// Cardinality is not fan-out. This selects one CodeableConcept, which
695    /// `indexing_conversion` turns into one token per coding — the caller has
696    /// to combine the two, which is why the leaf types are reported.
697    #[test]
698    fn a_singular_codeable_concept_still_reports_its_type() {
699        let code = resolved("Observation.code");
700
701        assert!(!code.repeats, "Observation.code is 1..1");
702        assert_eq!(types(&code), ["CodeableConcept"]);
703    }
704
705    /// A choice element reports every type it may hold.
706    #[test]
707    fn a_choice_element_reports_all_its_types() {
708        let effective = resolved("Observation.effective");
709
710        assert!(!effective.repeats);
711        assert!(types(&effective).contains(&"Timing"));
712        assert!(types(&effective).contains(&"dateTime"));
713    }
714
715    #[test]
716    fn a_singular_reference_resolves() {
717        let subject = resolved("Observation.subject");
718
719        assert!(!subject.repeats);
720        assert_eq!(types(&subject), ["Reference"]);
721    }
722
723    /// A recursive structure carries its children by `contentReference`.
724    #[test]
725    fn content_references_are_followed() {
726        assert!(resolved("Questionnaire.item.item.text").repeats);
727    }
728
729    #[test]
730    fn an_unknown_segment_is_reported_not_guessed() {
731        assert_eq!(
732            analyze_path(&index(), "Patient.notAnElement"),
733            PathAnalysis::Unresolved {
734                reached: "Patient".to_string(),
735                segment: "notAnElement".to_string(),
736            }
737        );
738    }
739
740    #[test]
741    fn expressions_needing_the_engine_are_not_plain_paths() {
742        for expression in [
743            "Patient.name.where(use='official')",
744            "Patient.deceased.ofType(dateTime)",
745            "(Observation.value as Quantity)",
746            "Patient.extension[0]",
747        ] {
748            assert_eq!(
749                analyze_path(&index(), expression),
750                PathAnalysis::NotAPlainPath,
751                "{expression}",
752            );
753        }
754    }
755
756    #[test]
757    fn branches_reduce_to_a_path_and_a_cast() {
758        let branch = |path: &str, cast: Option<&str>| Branch {
759            path: path.to_string(),
760            cast: cast.map(ToString::to_string),
761        };
762
763        assert_eq!(
764            simplify_branch("Observation.subject.where(resolve() is Patient)"),
765            Some(branch("Observation.subject", None))
766        );
767        assert_eq!(
768            simplify_branch("(RiskAssessment.occurrence.ofType(dateTime))"),
769            Some(branch("RiskAssessment.occurrence", Some("dateTime")))
770        );
771        assert_eq!(
772            simplify_branch("(Observation.value as Quantity)"),
773            Some(branch("Observation.value", Some("Quantity")))
774        );
775        assert_eq!(simplify_branch("Patient.extension[0]"), None);
776        assert_eq!(simplify_branch("Patient.name.first()"), None);
777    }
778
779    /// A `|` inside a filter's argument is not a branch boundary.
780    #[test]
781    fn the_union_splits_only_at_the_top_level() {
782        assert_eq!(
783            split_union("A.b.where(c | d) | E.f"),
784            ["A.b.where(c | d)", "E.f"]
785        );
786    }
787
788    fn single(expression: &str, base: &str) -> bool {
789        is_single_valued_for(&index(), expression, base)
790    }
791
792    /// Each resource type takes only its own branch of a shared parameter.
793    #[test]
794    fn a_union_is_judged_per_resource_type() {
795        let birthdate = "Patient.birthDate | Person.birthDate | RelatedPerson.birthDate";
796        assert!(single(birthdate, "Patient"));
797        assert!(single(birthdate, "Person"));
798
799        // `DocumentReference.context.encounter` repeats; the others do not,
800        // and one type's repetition no longer costs every other type its
801        // column.
802        let encounter = "DocumentReference.context.encounter | Observation.encounter";
803        assert!(!single(encounter, "DocumentReference"));
804        assert!(single(encounter, "Observation"));
805    }
806
807    /// `clinical-patient` filters each type's subject down to patients; a
808    /// filter only removes values, so the subject's cardinality bounds it.
809    #[test]
810    fn a_filtered_branch_is_as_single_as_its_path() {
811        assert!(single(
812            "AllergyIntolerance.patient | Observation.subject.where(resolve() is Patient)",
813            "Observation"
814        ));
815        assert!(single(
816            "AllergyIntolerance.patient | Observation.subject.where(resolve() is Patient)",
817            "AllergyIntolerance"
818        ));
819    }
820
821    /// A choice element that may hold a fanning-out type is not single, even
822    /// though it holds one value: when that value is a `Timing` it becomes
823    /// several dates. A cast to a type that does not fan out is single.
824    #[test]
825    fn a_choice_is_single_only_if_none_of_its_types_fans_out() {
826        assert!(!single("Observation.effective", "Observation"));
827        assert!(single(
828            "(RiskAssessment.occurrence.ofType(dateTime))",
829            "RiskAssessment"
830        ));
831        assert!(single("Encounter.period", "Encounter"));
832    }
833
834    /// Several branches over one choice element, each cast differently, are
835    /// mutually exclusive; branches over different elements are not.
836    #[test]
837    fn branches_for_one_type_must_select_the_same_element() {
838        assert!(single(
839            "(Observation.value as Quantity) | (Observation.value as SampledData)",
840            "Observation"
841        ));
842        assert!(!single(
843            "Observation.issued | Observation.effective",
844            "Observation"
845        ));
846    }
847
848    /// A branch the walk cannot reduce leaves its own type unanswered, and
849    /// only its own type.
850    #[test]
851    fn an_unreducible_branch_only_affects_its_type() {
852        let expression = "Patient.extension[0] | Observation.subject";
853        assert!(!single(expression, "Patient"));
854        assert!(single(expression, "Observation"));
855    }
856
857    #[test]
858    fn a_type_the_parameter_has_no_branch_for_is_not_single() {
859        assert!(!single("Observation.subject", "Condition"));
860    }
861
862    /// A parameter selecting one `CodeableConcept` is not single valued, even
863    /// though its path does not repeat, because the conversion fans it out.
864    #[test]
865    fn fanning_out_types_are_not_single_valued() {
866        assert!(!single("Observation.code", "Observation"));
867        assert!(single("Observation.subject", "Observation"));
868        assert!(single("Patient.birthDate", "Patient"));
869    }
870
871    /// The shared clinical parameters, which is what the per-type analysis is
872    /// for: each type now answers for its own branch.
873    #[test]
874    fn the_clinical_parameters_classify_per_type() {
875        let expression = |id: &str| {
876            SEARCH_PARAMETERS
877                .iter()
878                .find(|p| p.id.as_deref() == Some(id))
879                .and_then(|p| p.expression.as_ref())
880                .and_then(|e| e.value.clone())
881                .unwrap_or_else(|| panic!("{id} has an expression"))
882        };
883
884        let patient = expression("clinical-patient");
885        for base in [
886            "Observation",
887            "Condition",
888            "Encounter",
889            "Procedure",
890            "AllergyIntolerance",
891        ] {
892            assert!(single(&patient, base), "clinical-patient on {base}");
893        }
894
895        let encounter = expression("clinical-encounter");
896        assert!(single(&encounter, "Observation"));
897        assert!(!single(&encounter, "DocumentReference"));
898
899        let date = expression("clinical-date");
900        assert!(single(&date, "Encounter"));
901        assert!(
902            !single(&date, "Observation"),
903            "effective[x] may be a Timing"
904        );
905
906        let code = expression("clinical-code");
907        assert!(!single(&code, "Observation"), "a CodeableConcept fans out");
908    }
909
910    /// Runs the whole HL7 base corpus per (parameter, type), so a change in
911    /// the walker or the artifacts shows up as a shift in these counts rather
912    /// than silently reclassifying parameters.
913    #[test]
914    fn the_base_corpus_classifies_stably() {
915        let index = index();
916        let (mut pairs, mut single) = (0, 0);
917
918        for parameter in SEARCH_PARAMETERS.iter() {
919            let Some(expression) = parameter
920                .expression
921                .as_ref()
922                .and_then(|e| e.value.as_deref())
923            else {
924                continue;
925            };
926
927            for base in parameter.base.iter().filter_map(|b| b.as_str()) {
928                pairs += 1;
929                if is_single_valued_for(&index, expression, base) {
930                    single += 1;
931                }
932            }
933        }
934
935        println!("single={single} of {pairs} (parameter, type) pairs");
936        assert!(
937            single >= SINGLE_PAIRS_FLOOR,
938            "single pairs shrank to {single}, which shrinks the scalar-column win",
939        );
940    }
941}