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//! This answers the question for *plain dotted paths* — `Patient.birthDate`,
10//! `Patient.name.family` — which is 86% of the HL7 base parameters. It reads
11//! the schema rather than running anything, so the answer is exact and needs
12//! no sample data. Expressions carrying `where()`, `ofType()`, a union or an
13//! index accessor are reported as [`PathAnalysis::NotAPlainPath`] and belong to
14//! the measuring analyzer in `haste-fhirpath`, which runs them.
15//!
16//! Note what this deliberately does not answer. Cardinality is the count of
17//! *values the expression selects*, not the count of *index entries they
18//! become*: `Observation.code` selects one `CodeableConcept`, which fans out to
19//! one token per coding. Callers have to combine `repeats` with the leaf type's
20//! fan-out, which is why [`ResolvedPath::leaf_type`] is reported alongside.
21
22use std::collections::HashMap;
23use std::fmt::Write as _;
24
25use haste_fhir_model::r4::generated::{resources::StructureDefinition, types::ElementDefinition};
26
27use crate::utilities::extract::{self, Max};
28
29/// What walking a path against the snapshots established.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PathAnalysis {
32    Resolved(ResolvedPath),
33    /// A segment had no matching element. Reported rather than guessed at: a
34    /// path the walker cannot follow must not be assumed singular.
35    Unresolved {
36        /// The element path reached before the walk failed.
37        reached: String,
38        /// The segment that could not be found under it.
39        segment: String,
40    },
41    /// The expression is not a plain dotted path, so the schema alone cannot
42    /// answer it.
43    NotAPlainPath,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPath {
48    /// Whether any element along the path may occur more than once. True for
49    /// `Patient.name.family`, because `name` repeats even though `family`
50    /// does not.
51    pub repeats: bool,
52    /// The FHIR type of the element the path ends on, when the element
53    /// declares exactly one. `None` for a choice element or a backbone.
54    pub leaf_type: Option<String>,
55}
56
57/// The snapshots a path is resolved against, keyed by the type they define.
58pub struct SnapshotIndex<'a> {
59    by_type: HashMap<&'a str, &'a StructureDefinition>,
60}
61
62impl<'a> SnapshotIndex<'a> {
63    /// Indexes definitions by their `type`, ignoring any without a snapshot —
64    /// a differential alone cannot be walked.
65    #[must_use]
66    pub fn new(definitions: impl IntoIterator<Item = &'a StructureDefinition>) -> Self {
67        let mut by_type = HashMap::new();
68
69        for sd in definitions {
70            if sd.snapshot.is_none() {
71                continue;
72            }
73            if let Some(type_name) = sd.type_.value.as_deref() {
74                by_type.insert(type_name, sd);
75            }
76        }
77
78        Self { by_type }
79    }
80
81    fn elements(&self, type_name: &str) -> Option<&'a [ElementDefinition]> {
82        self.by_type
83            .get(type_name)?
84            .snapshot
85            .as_ref()
86            .map(|snapshot| snapshot.element.as_slice())
87    }
88
89    /// Finds the element at exactly `path` within the definition of the type
90    /// that path starts with.
91    fn element_at(&self, path: &str) -> Option<&'a ElementDefinition> {
92        let type_name = path.split('.').next()?;
93
94        self.elements(type_name)?
95            .iter()
96            .find(|element| element.path.value.as_deref() == Some(path))
97    }
98}
99
100/// Whether every character is one a plain dotted path can contain. Anything
101/// else — a call, a union, an index — means the schema alone cannot answer the
102/// question.
103fn is_plain_path(expression: &str) -> bool {
104    !expression.is_empty()
105        && expression
106            .chars()
107            .all(|c| c.is_ascii_alphanumeric() || c == '.')
108        && !expression.starts_with('.')
109        && !expression.ends_with('.')
110}
111
112fn repeats(element: &ElementDefinition) -> bool {
113    !matches!(extract::cardinality(element).1, Max::Fixed(1))
114}
115
116/// The single type an element declares, if it declares exactly one. A choice
117/// element declares several and a backbone declares `BackboneElement`, neither
118/// of which identifies where the walk continues on its own.
119fn sole_type(element: &ElementDefinition) -> Option<&str> {
120    match extract::field_types(element).as_slice() {
121        [single] => Some(single),
122        _ => None,
123    }
124}
125
126/// Walks `expression` through the snapshots, segment by segment.
127///
128/// The walk crosses definitions: once a path leaves the resource's own
129/// elements — `Patient.name` is a `HumanName` — it continues in the definition
130/// of that type. `contentReference` is followed the same way, which is what
131/// lets a recursive structure like `Questionnaire.item.item` resolve.
132#[must_use]
133pub fn analyze_path(index: &SnapshotIndex, expression: &str) -> PathAnalysis {
134    // A union of plain paths is still answerable: a base search parameter is
135    // routinely written `Patient.birthDate | Person.birthDate | ...`, one
136    // branch per resource type it applies to. Whichever branch a given
137    // resource takes, the parameter is singular only if none of them repeats,
138    // so the union is as repeating as its most repeating branch.
139    if expression.contains('|') {
140        let mut repeats = false;
141        let mut leaf_types = Vec::new();
142
143        for branch in expression.split('|') {
144            match analyze_path(index, branch.trim()) {
145                PathAnalysis::Resolved(path) => {
146                    repeats |= path.repeats;
147                    leaf_types.push(path.leaf_type);
148                }
149                // One unanalyzable branch leaves the whole union unanswered.
150                other => return other,
151            }
152        }
153
154        // Only report a leaf type the whole union agrees on; a caller applies
155        // fan-out rules to it, and disagreeing branches have no single answer.
156        let leaf_type = leaf_types
157            .first()
158            .filter(|first| leaf_types.iter().all(|leaf| leaf == *first))
159            .cloned()
160            .flatten();
161
162        return PathAnalysis::Resolved(ResolvedPath { repeats, leaf_type });
163    }
164
165    if !is_plain_path(expression) {
166        return PathAnalysis::NotAPlainPath;
167    }
168
169    let mut segments = expression.split('.');
170
171    // The first segment names the type the walk starts in.
172    let Some(root) = segments.next() else {
173        return PathAnalysis::NotAPlainPath;
174    };
175
176    let Some(root_element) = index.element_at(root) else {
177        return PathAnalysis::Unresolved {
178            reached: String::new(),
179            segment: root.to_string(),
180        };
181    };
182
183    let mut current = root_element;
184    let mut current_path = root.to_string();
185    let mut saw_repeat = false;
186
187    for segment in segments {
188        let Some(next) = step(index, current, &current_path, segment) else {
189            return PathAnalysis::Unresolved {
190                reached: current_path,
191                segment: segment.to_string(),
192            };
193        };
194
195        saw_repeat |= repeats(next.element);
196        current = next.element;
197        current_path = next.path;
198    }
199
200    PathAnalysis::Resolved(ResolvedPath {
201        repeats: saw_repeat,
202        leaf_type: sole_type(current).map(ToString::to_string),
203    })
204}
205
206struct Step<'a> {
207    element: &'a ElementDefinition,
208    path: String,
209}
210
211/// Resolves one segment below `current`, following into another definition or
212/// a content reference when the element is not defined inline.
213fn step<'a>(
214    index: &SnapshotIndex<'a>,
215    current: &'a ElementDefinition,
216    current_path: &str,
217    segment: &str,
218) -> Option<Step<'a>> {
219    // Defined inline, as a backbone element's children are.
220    let inline = format!("{current_path}.{segment}");
221    if let Some(element) = index.element_at(&inline) {
222        return Some(Step {
223            element,
224            path: inline,
225        });
226    }
227
228    // A choice element is written `value[x]` but named `value` in a path.
229    let choice = format!("{current_path}.{segment}[x]");
230    if let Some(element) = index.element_at(&choice) {
231        return Some(Step {
232            element,
233            path: choice,
234        });
235    }
236
237    // Recursive structures carry their children by reference rather than
238    // repeating them, so the walk continues wherever the reference points.
239    if let Some(target) = current
240        .contentReference
241        .as_ref()
242        .and_then(|r| r.value.as_deref())
243        .and_then(|r| r.strip_prefix('#'))
244    {
245        let referenced = format!("{target}.{segment}");
246        if let Some(element) = index.element_at(&referenced) {
247            return Some(Step {
248                element,
249                path: referenced,
250            });
251        }
252    }
253
254    // Otherwise the path has left this definition and continues in the one for
255    // the element's own type.
256    let type_name = sole_type(current)?;
257    let in_type = format!("{type_name}.{segment}");
258    index.element_at(&in_type).map(|element| Step {
259        element,
260        path: in_type,
261    })
262}
263
264/// Datatypes whose conversion to index values emits more than one entry for a
265/// single value, so a path that selects exactly one of them still produces
266/// several index entries.
267///
268/// This mirrors the per-type arms of `indexing_conversion` in
269/// `haste-fhir-search`: a `HumanName` becomes its text, family, every given,
270/// every prefix and every suffix; a `CodeableConcept` becomes one token per
271/// coding. Changing a converter to fan out — encoding an `Identifier` as both
272/// `system|value` and bare `value`, say — means adding its type here, or the
273/// generated table starts claiming parameters are singular when their values
274/// are being dropped.
275pub const FANNING_OUT_TYPES: [&str; 4] = ["HumanName", "Address", "CodeableConcept", "Timing"];
276
277/// Whether a parameter produces at most one index entry per resource.
278///
279/// Both halves have to hold: the path selects at most one value, *and* that
280/// value converts to at most one index entry. Anything the walker could not
281/// resolve, or that needs the engine, is not single — the cost of being wrong
282/// that way is slower storage, and the cost of being wrong the other way is
283/// silently dropping values.
284#[must_use]
285pub fn is_single_valued(index: &SnapshotIndex, expression: &str) -> bool {
286    match analyze_path(index, expression) {
287        PathAnalysis::Resolved(path) => {
288            !path.repeats
289                && !path
290                    .leaf_type
291                    .as_deref()
292                    .is_some_and(|leaf| FANNING_OUT_TYPES.contains(&leaf))
293        }
294        PathAnalysis::Unresolved { .. } | PathAnalysis::NotAPlainPath => false,
295    }
296}
297
298/// Reads every `StructureDefinition` under the given files or directories,
299/// following the same JSON-file walk the other generators use. Bundles are
300/// unwrapped, so a `profiles-resources.min.json` can be passed directly.
301///
302/// # Errors
303///
304/// Returns an error if a path cannot be read or a file is not valid JSON.
305pub fn load_definitions(paths: &[String]) -> Result<Vec<StructureDefinition>, String> {
306    load_resources(paths, |resource| match resource {
307        haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(sd) => Some(sd),
308        _ => None,
309    })
310}
311
312/// Reads every `SearchParameter` under the given files or directories.
313///
314/// # Errors
315///
316/// Returns an error if a path cannot be read or a file is not valid JSON.
317pub fn load_search_parameters(
318    paths: &[String],
319) -> Result<Vec<haste_fhir_model::r4::generated::resources::SearchParameter>, String> {
320    load_resources(paths, |resource| match resource {
321        haste_fhir_model::r4::generated::resources::Resource::SearchParameter(sp) => Some(sp),
322        _ => None,
323    })
324}
325
326fn load_resources<T>(
327    paths: &[String],
328    pick: impl Fn(haste_fhir_model::r4::generated::resources::Resource) -> Option<T> + Copy,
329) -> Result<Vec<T>, String> {
330    use haste_fhir_model::r4::generated::resources::Resource;
331
332    let mut collected = Vec::new();
333
334    for path in paths {
335        for entry in walkdir::WalkDir::new(path)
336            .sort_by_file_name()
337            .into_iter()
338            .filter_map(Result::ok)
339            .filter(|e| e.metadata().is_ok_and(|m| m.is_file()))
340            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
341        {
342            let contents = std::fs::read_to_string(entry.path())
343                .map_err(|e| format!("{}: {e}", entry.path().display()))?;
344
345            let resource: Resource = serde_json::from_str(&contents)
346                .map_err(|e| format!("{}: {e}", entry.path().display()))?;
347
348            match resource {
349                // A bundle of definitions, as the HL7 packages ship them.
350                Resource::Bundle(bundle) => {
351                    collected.extend(
352                        bundle
353                            .entry
354                            .unwrap_or_default()
355                            .into_iter()
356                            .filter_map(|e| e.resource)
357                            .filter_map(|r| pick(*r)),
358                    );
359                }
360                resource => collected.extend(pick(resource)),
361            }
362        }
363    }
364
365    Ok(collected)
366}
367
368/// Emits the Rust source for the compiled lookup table.
369///
370/// The table is the sorted set of canonical URLs whose parameters are single
371/// valued, so a lookup is a binary search over static data with no
372/// initialisation. Absence means "not known to be single", which is the answer
373/// a caller should act on anyway for a URL it has never heard of.
374#[must_use]
375pub fn generate_lookup(
376    definitions: &[StructureDefinition],
377    search_parameters: &[haste_fhir_model::r4::generated::resources::SearchParameter],
378) -> String {
379    let index = SnapshotIndex::new(definitions.iter());
380
381    let mut urls: Vec<&str> = search_parameters
382        .iter()
383        .filter_map(|parameter| {
384            let url = parameter.url.value.as_deref()?;
385            let expression = parameter.expression.as_ref()?.value.as_deref()?;
386
387            is_single_valued(&index, expression).then_some(url)
388        })
389        .collect();
390
391    urls.sort_unstable();
392    urls.dedup();
393
394    let entries = urls.iter().fold(String::new(), |mut entries, url| {
395        let _ = writeln!(entries, "    {url:?},");
396        entries
397    });
398
399    format!(
400        r#"//! Search parameters that produce at most one index value per resource.
401//!
402//! @generated by `bash scripts/search_param_cardinality_build.sh` — do not edit.
403//!
404//! A parameter listed here selects at most one value and converts to at most
405//! one index entry, so it can be stored as a scalar column, which is what lets
406//! an index answer an ordered comparison, a prefix match or a sort.
407//!
408//! Absence means "not known to be single". A parameter whose expression needs
409//! the `FHIRPath` engine to resolve, or that the schema walk could not follow,
410//! is absent for the same reason a genuinely repeating one is: storing several
411//! values in a scalar column keeps the first and drops the rest.
412
413/// Canonical URLs of the single-valued parameters, sorted for binary search.
414static SINGLE_VALUED: [&str; {count}] = [
415{entries}];
416
417/// Whether `url` names a parameter that produces at most one index value.
418///
419/// Unknown URLs answer `false`, which is the safe direction: a caller that
420/// treats an unclassified parameter as multi valued is slower, one that treats
421/// it as single loses data.
422#[must_use]
423pub fn is_single_valued(url: &str) -> bool {{
424    SINGLE_VALUED.binary_search(&url).is_ok()
425}}
426"#,
427        count = urls.len(),
428        entries = entries,
429    )
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use haste_fhir_model::r4::generated::resources::{Bundle, Resource, SearchParameter};
436    use std::sync::LazyLock;
437
438    fn definitions_from(json: &str) -> Vec<StructureDefinition> {
439        serde_json::from_str::<Bundle>(json)
440            .expect("bundle parses")
441            .entry
442            .unwrap_or_default()
443            .into_iter()
444            .filter_map(|e| e.resource)
445            .filter_map(|r| match *r {
446                Resource::StructureDefinition(sd) => Some(sd),
447                _ => None,
448            })
449            .collect()
450    }
451
452    static DEFINITIONS: LazyLock<Vec<StructureDefinition>> = LazyLock::new(|| {
453        let mut all = definitions_from(include_str!(
454            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-resources.min.json"
455        ));
456        all.extend(definitions_from(include_str!(
457            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-types.min.json"
458        )));
459        all
460    });
461
462    static SEARCH_PARAMETERS: LazyLock<Vec<SearchParameter>> = LazyLock::new(|| {
463        serde_json::from_str::<Bundle>(include_str!(
464            "../../../../artifacts/r4/hl7-core/definitions/hl7/search-parameters.min.json"
465        ))
466        .expect("bundle parses")
467        .entry
468        .unwrap_or_default()
469        .into_iter()
470        .filter_map(|e| e.resource)
471        .filter_map(|r| match *r {
472            Resource::SearchParameter(sp) => Some(sp),
473            _ => None,
474        })
475        .collect()
476    });
477
478    fn index() -> SnapshotIndex<'static> {
479        SnapshotIndex::new(DEFINITIONS.iter())
480    }
481
482    fn resolved(expression: &str) -> ResolvedPath {
483        match analyze_path(&index(), expression) {
484            PathAnalysis::Resolved(resolved) => resolved,
485            other => panic!("{expression} did not resolve: {other:?}"),
486        }
487    }
488
489    #[test]
490    fn a_singular_element_does_not_repeat() {
491        let birth_date = resolved("Patient.birthDate");
492
493        assert!(!birth_date.repeats);
494        assert_eq!(birth_date.leaf_type.as_deref(), Some("date"));
495    }
496
497    /// `family` is `0..1`, but `name` above it is `0..*`, so the path repeats.
498    #[test]
499    fn a_repeating_element_anywhere_on_the_path_repeats() {
500        assert!(resolved("Patient.name.family").repeats);
501        assert!(resolved("Patient.name").repeats);
502    }
503
504    /// The walk crosses into another definition when the path leaves the
505    /// resource's own elements: `name` is a `HumanName`, `family` lives there.
506    #[test]
507    fn the_walk_crosses_into_complex_types() {
508        assert_eq!(
509            resolved("Patient.name.family").leaf_type.as_deref(),
510            Some("string")
511        );
512        // Two crossings: Patient.contact is a backbone, its name a HumanName.
513        assert!(resolved("Patient.contact.name.family").repeats);
514    }
515
516    /// Cardinality is not fan-out. This selects one CodeableConcept, which
517    /// `indexing_conversion` turns into one token per coding — the caller has
518    /// to combine the two, which is why the leaf type is reported.
519    #[test]
520    fn a_singular_codeable_concept_still_reports_its_type() {
521        let code = resolved("Observation.code");
522
523        assert!(!code.repeats, "Observation.code is 1..1");
524        assert_eq!(code.leaf_type.as_deref(), Some("CodeableConcept"));
525    }
526
527    #[test]
528    fn a_singular_reference_resolves() {
529        let subject = resolved("Observation.subject");
530
531        assert!(!subject.repeats);
532        assert_eq!(subject.leaf_type.as_deref(), Some("Reference"));
533    }
534
535    /// A recursive structure carries its children by `contentReference`.
536    #[test]
537    fn content_references_are_followed() {
538        assert!(resolved("Questionnaire.item.item.text").repeats);
539    }
540
541    #[test]
542    fn an_unknown_segment_is_reported_not_guessed() {
543        assert_eq!(
544            analyze_path(&index(), "Patient.notAnElement"),
545            PathAnalysis::Unresolved {
546                reached: "Patient".to_string(),
547                segment: "notAnElement".to_string(),
548            }
549        );
550    }
551
552    /// A base parameter written as one branch per resource type is singular
553    /// when no branch repeats.
554    #[test]
555    fn a_union_of_singular_paths_is_singular() {
556        let birthdate = resolved("Patient.birthDate | Person.birthDate | RelatedPerson.birthDate");
557
558        assert!(!birthdate.repeats);
559        assert_eq!(birthdate.leaf_type.as_deref(), Some("date"));
560    }
561
562    /// One repeating branch makes the whole union repeating, because a
563    /// resource taking that branch would have values to drop.
564    #[test]
565    fn a_union_with_a_repeating_branch_repeats() {
566        assert!(resolved("Patient.birthDate | Patient.name.family").repeats);
567    }
568
569    #[test]
570    fn expressions_needing_the_engine_are_declined() {
571        for expression in [
572            "Patient.name.where(use='official')",
573            "Patient.deceased.ofType(dateTime)",
574            "(Observation.value as Quantity)",
575            "Patient.extension[0]",
576        ] {
577            assert_eq!(
578                analyze_path(&index(), expression),
579                PathAnalysis::NotAPlainPath,
580                "{expression}",
581            );
582        }
583    }
584
585    /// A parameter selecting one `CodeableConcept` is not single valued, even
586    /// though its path does not repeat, because the conversion fans it out.
587    #[test]
588    fn fanning_out_types_are_not_single_valued() {
589        let index = index();
590
591        assert!(!is_single_valued(&index, "Observation.code"));
592        assert!(is_single_valued(&index, "Observation.subject"));
593        assert!(is_single_valued(&index, "Patient.birthDate"));
594    }
595
596    /// Runs the whole HL7 base corpus, so a change in the walker or the
597    /// artifacts shows up as a shift in these counts rather than silently
598    /// reclassifying parameters.
599    #[test]
600    fn the_base_corpus_classifies_stably() {
601        let index = index();
602        let (mut single, mut many, mut not_plain, mut unresolved) = (0, 0, 0, 0);
603
604        for parameter in SEARCH_PARAMETERS.iter() {
605            let Some(expression) = parameter
606                .expression
607                .as_ref()
608                .and_then(|e| e.value.as_deref())
609            else {
610                continue;
611            };
612
613            match analyze_path(&index, expression) {
614                PathAnalysis::Resolved(path) if path.repeats => many += 1,
615                PathAnalysis::Resolved(_) => single += 1,
616                PathAnalysis::NotAPlainPath => not_plain += 1,
617                PathAnalysis::Unresolved { .. } => unresolved += 1,
618            }
619        }
620
621        let total = single + many + not_plain + unresolved;
622        assert_eq!(total, 1372, "corpus size");
623
624        // Every plain path the walker declines to resolve is a parameter that
625        // falls back to the slower storage, so the count is worth watching.
626        assert!(
627            unresolved <= 15,
628            "unresolved plain paths grew: {unresolved}",
629        );
630        assert!(
631            single >= 600,
632            "singular paths shrank to {single}, which shrinks the scalar-column win",
633        );
634
635        println!("single={single} many={many} not_plain={not_plain} unresolved={unresolved}");
636    }
637}