1use 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PathAnalysis {
32 Resolved(ResolvedPath),
33 Unresolved {
36 reached: String,
38 segment: String,
40 },
41 NotAPlainPath,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPath {
48 pub repeats: bool,
52 pub leaf_type: Option<String>,
55}
56
57pub struct SnapshotIndex<'a> {
59 by_type: HashMap<&'a str, &'a StructureDefinition>,
60}
61
62impl<'a> SnapshotIndex<'a> {
63 #[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 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
100fn 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
116fn sole_type(element: &ElementDefinition) -> Option<&str> {
120 match extract::field_types(element).as_slice() {
121 [single] => Some(single),
122 _ => None,
123 }
124}
125
126#[must_use]
133pub fn analyze_path(index: &SnapshotIndex, expression: &str) -> PathAnalysis {
134 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 other => return other,
151 }
152 }
153
154 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 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, ¤t_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
211fn step<'a>(
214 index: &SnapshotIndex<'a>,
215 current: &'a ElementDefinition,
216 current_path: &str,
217 segment: &str,
218) -> Option<Step<'a>> {
219 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 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 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 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
264pub const FANNING_OUT_TYPES: [&str; 4] = ["HumanName", "Address", "CodeableConcept", "Timing"];
276
277#[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
298pub 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
312pub 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 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#[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 #[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 #[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 assert!(resolved("Patient.contact.name.family").repeats);
514 }
515
516 #[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 #[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 #[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 #[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 #[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 #[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 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}