1use 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#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PathAnalysis {
40 Resolved(ResolvedPath),
41 Unresolved {
44 reached: String,
46 segment: String,
48 },
49 NotAPlainPath,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResolvedPath {
56 pub repeats: bool,
60 pub leaf_types: Vec<String>,
63}
64
65pub struct SnapshotIndex<'a> {
67 by_type: HashMap<&'a str, &'a StructureDefinition>,
68}
69
70impl<'a> SnapshotIndex<'a> {
71 #[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 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
108fn 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
124fn sole_type(element: &ElementDefinition) -> Option<&str> {
128 match extract::field_types(element).as_slice() {
129 [single] => Some(single),
130 _ => None,
131 }
132}
133
134#[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 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, ¤t_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
191fn step<'a>(
194 index: &SnapshotIndex<'a>,
195 current: &'a ElementDefinition,
196 current_path: &str,
197 segment: &str,
198) -> Option<Step<'a>> {
199 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 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 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 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
244pub const FANNING_OUT_TYPES: [&str; 4] = ["HumanName", "Address", "CodeableConcept", "Timing"];
256
257#[derive(Debug, Clone, PartialEq, Eq)]
260struct Branch {
261 path: String,
262 cast: Option<String>,
263}
264
265fn 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
275fn 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
297fn 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
319fn 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
350fn 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 let (text, cast) = match text.split_once(" as ") {
360 Some((path, cast)) => (path.trim(), Some(cast.trim())),
361 None => (text, None),
362 };
363
364 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
385const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
387
388#[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 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
450pub 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
464pub 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 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#[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 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 #[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 #[test]
688 fn the_walk_crosses_into_complex_types() {
689 assert_eq!(types(&resolved("Patient.name.family")), ["string"]);
690 assert!(resolved("Patient.contact.name.family").repeats);
692 }
693
694 #[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 #[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 #[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 #[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 #[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 let encounter = "DocumentReference.context.encounter | Observation.encounter";
803 assert!(!single(encounter, "DocumentReference"));
804 assert!(single(encounter, "Observation"));
805 }
806
807 #[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 #[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 #[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 #[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 #[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 #[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 #[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}