use std::collections::HashMap;
use std::fmt::Write as _;
use haste_fhir_model::r4::generated::{resources::StructureDefinition, types::ElementDefinition};
use crate::utilities::extract::{self, Max};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathAnalysis {
Resolved(ResolvedPath),
Unresolved {
reached: String,
segment: String,
},
NotAPlainPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPath {
pub repeats: bool,
pub leaf_types: Vec<String>,
}
pub struct SnapshotIndex<'a> {
by_type: HashMap<&'a str, &'a StructureDefinition>,
}
impl<'a> SnapshotIndex<'a> {
#[must_use]
pub fn new(definitions: impl IntoIterator<Item = &'a StructureDefinition>) -> Self {
let mut by_type = HashMap::new();
for sd in definitions {
if sd.snapshot.is_none() {
continue;
}
if let Some(type_name) = sd.type_.value.as_deref() {
by_type.insert(type_name, sd);
}
}
Self { by_type }
}
fn elements(&self, type_name: &str) -> Option<&'a [ElementDefinition]> {
self.by_type
.get(type_name)?
.snapshot
.as_ref()
.map(|snapshot| snapshot.element.as_slice())
}
fn element_at(&self, path: &str) -> Option<&'a ElementDefinition> {
let type_name = path.split('.').next()?;
self.elements(type_name)?
.iter()
.find(|element| element.path.value.as_deref() == Some(path))
}
}
fn is_plain_path(expression: &str) -> bool {
!expression.is_empty()
&& expression
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.')
&& !expression.starts_with('.')
&& !expression.ends_with('.')
}
fn repeats(element: &ElementDefinition) -> bool {
!matches!(extract::cardinality(element).1, Max::Fixed(1))
}
fn sole_type(element: &ElementDefinition) -> Option<&str> {
match extract::field_types(element).as_slice() {
[single] => Some(single),
_ => None,
}
}
#[must_use]
pub fn analyze_path(index: &SnapshotIndex, expression: &str) -> PathAnalysis {
if !is_plain_path(expression) {
return PathAnalysis::NotAPlainPath;
}
let mut segments = expression.split('.');
let Some(root) = segments.next() else {
return PathAnalysis::NotAPlainPath;
};
let Some(root_element) = index.element_at(root) else {
return PathAnalysis::Unresolved {
reached: String::new(),
segment: root.to_string(),
};
};
let mut current = root_element;
let mut current_path = root.to_string();
let mut saw_repeat = false;
for segment in segments {
let Some(next) = step(index, current, ¤t_path, segment) else {
return PathAnalysis::Unresolved {
reached: current_path,
segment: segment.to_string(),
};
};
saw_repeat |= repeats(next.element);
current = next.element;
current_path = next.path;
}
PathAnalysis::Resolved(ResolvedPath {
repeats: saw_repeat,
leaf_types: extract::field_types(current)
.into_iter()
.map(ToString::to_string)
.collect(),
})
}
struct Step<'a> {
element: &'a ElementDefinition,
path: String,
}
fn step<'a>(
index: &SnapshotIndex<'a>,
current: &'a ElementDefinition,
current_path: &str,
segment: &str,
) -> Option<Step<'a>> {
let inline = format!("{current_path}.{segment}");
if let Some(element) = index.element_at(&inline) {
return Some(Step {
element,
path: inline,
});
}
let choice = format!("{current_path}.{segment}[x]");
if let Some(element) = index.element_at(&choice) {
return Some(Step {
element,
path: choice,
});
}
if let Some(target) = current
.contentReference
.as_ref()
.and_then(|r| r.value.as_deref())
.and_then(|r| r.strip_prefix('#'))
{
let referenced = format!("{target}.{segment}");
if let Some(element) = index.element_at(&referenced) {
return Some(Step {
element,
path: referenced,
});
}
}
let type_name = sole_type(current)?;
let in_type = format!("{type_name}.{segment}");
index.element_at(&in_type).map(|element| Step {
element,
path: in_type,
})
}
pub const FANNING_OUT_TYPES: [&str; 4] = ["HumanName", "Address", "CodeableConcept", "Timing"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct Branch {
path: String,
cast: Option<String>,
}
fn branch_root(branch: &str) -> &str {
let branch = branch.trim_start_matches(|c: char| c == '(' || c.is_whitespace());
let end = branch
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(branch.len());
&branch[..end]
}
fn split_union(expression: &str) -> Vec<&str> {
let mut branches = Vec::new();
let mut depth = 0usize;
let mut start = 0;
for (at, c) in expression.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
'|' if depth == 0 => {
branches.push(expression[start..at].trim());
start = at + 1;
}
_ => {}
}
}
branches.push(expression[start..].trim());
branches
}
fn is_wrapped(text: &str) -> bool {
if !text.starts_with('(') || !text.ends_with(')') {
return false;
}
let mut depth = 0usize;
for (at, c) in text.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 && at != text.len() - 1 {
return false;
}
}
_ => {}
}
}
true
}
fn strip_where(path: &str) -> Option<String> {
let mut out = String::with_capacity(path.len());
let mut rest = path;
while let Some(at) = rest.find(".where(") {
out.push_str(&rest[..at]);
let mut depth = 0usize;
let mut close = None;
for (offset, c) in rest[at + ".where".len()..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close = Some(at + ".where".len() + offset);
break;
}
}
_ => {}
}
}
rest = &rest[close? + 1..];
}
out.push_str(rest);
Some(out)
}
fn simplify_branch(branch: &str) -> Option<Branch> {
let mut text = branch.trim();
while is_wrapped(text) {
text = text[1..text.len() - 1].trim();
}
let (text, cast) = match text.split_once(" as ") {
Some((path, cast)) => (path.trim(), Some(cast.trim())),
None => (text, None),
};
let (text, cast) = match text
.strip_suffix(')')
.and_then(|t| t.rsplit_once(".ofType("))
{
Some((path, of_type)) if cast.is_none() => (path, Some(of_type)),
Some(_) => return None,
None => (text, cast),
};
let path = strip_where(text)?;
if !is_plain_path(&path) || cast.is_some_and(|cast| !is_plain_path(cast)) {
return None;
}
Some(Branch {
path,
cast: cast.map(ToString::to_string),
})
}
const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
#[must_use]
pub fn is_single_valued_for(index: &SnapshotIndex, expression: &str, base: &str) -> bool {
let mut applicable = Vec::new();
for branch in split_union(expression) {
let root = branch_root(branch);
if root != base && !UNIVERSAL_BASES.contains(&root) {
continue;
}
let Some(simplified) = simplify_branch(branch) else {
return false;
};
applicable.push(simplified);
}
let Some(first) = applicable.first() else {
return false;
};
if applicable.iter().any(|branch| branch.path != first.path) {
return false;
}
let PathAnalysis::Resolved(resolved) = analyze_path(index, &first.path) else {
return false;
};
if resolved.repeats {
return false;
}
let mut leaf_types: Vec<&str> = Vec::new();
for branch in &applicable {
match &branch.cast {
Some(cast) => leaf_types.push(cast),
None => leaf_types.extend(resolved.leaf_types.iter().map(String::as_str)),
}
}
!leaf_types.is_empty()
&& !leaf_types
.iter()
.any(|leaf| FANNING_OUT_TYPES.contains(leaf))
}
pub fn load_definitions(paths: &[String]) -> Result<Vec<StructureDefinition>, String> {
load_resources(paths, |resource| match resource {
haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(sd) => Some(sd),
_ => None,
})
}
pub fn load_search_parameters(
paths: &[String],
) -> Result<Vec<haste_fhir_model::r4::generated::resources::SearchParameter>, String> {
load_resources(paths, |resource| match resource {
haste_fhir_model::r4::generated::resources::Resource::SearchParameter(sp) => Some(sp),
_ => None,
})
}
fn load_resources<T>(
paths: &[String],
pick: impl Fn(haste_fhir_model::r4::generated::resources::Resource) -> Option<T> + Copy,
) -> Result<Vec<T>, String> {
use haste_fhir_model::r4::generated::resources::Resource;
let mut collected = Vec::new();
for path in paths {
for entry in walkdir::WalkDir::new(path)
.sort_by_file_name()
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.metadata().is_ok_and(|m| m.is_file()))
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
{
let contents = std::fs::read_to_string(entry.path())
.map_err(|e| format!("{}: {e}", entry.path().display()))?;
let resource: Resource = serde_json::from_str(&contents)
.map_err(|e| format!("{}: {e}", entry.path().display()))?;
match resource {
Resource::Bundle(bundle) => {
collected.extend(
bundle
.entry
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.resource)
.filter_map(|r| pick(*r)),
);
}
resource => collected.extend(pick(resource)),
}
}
}
Ok(collected)
}
#[must_use]
pub fn generate_lookup(
definitions: &[StructureDefinition],
search_parameters: &[haste_fhir_model::r4::generated::resources::SearchParameter],
) -> String {
let index = SnapshotIndex::new(definitions.iter());
let index = &index;
let mut pairs: Vec<(&str, &str)> = search_parameters
.iter()
.flat_map(|parameter| {
let url = parameter.url.value.as_deref();
let expression = parameter
.expression
.as_ref()
.and_then(|e| e.value.as_deref());
parameter.base.iter().filter_map(move |base| {
let (url, expression, base) = (url?, expression?, base.as_str()?);
is_single_valued_for(index, expression, base).then_some((url, base))
})
})
.collect();
pairs.sort_unstable();
pairs.dedup();
let entries = pairs
.iter()
.fold(String::new(), |mut entries, (url, base)| {
let _ = writeln!(entries, " ({url:?}, {base:?}),");
entries
});
format!(
r#"//! Search parameters that produce at most one index value per resource.
//!
//! @generated by `bash scripts/search_param_cardinality_build.sh` — do not edit.
//!
//! A parameter listed here for a resource type selects at most one value from
//! a resource of that type, and converts it to at most one index entry, so it
//! can be stored as a scalar column, which is what lets an index answer an
//! ordered comparison, a prefix match or a sort. The answer is per type:
//! a shared parameter like `clinical-patient` takes one branch of its union
//! per type, and can be single for some and repeating for others.
//!
//! Absence means "not known to be single". A parameter whose expression needs
//! the `FHIRPath` engine to resolve, or that the schema walk could not follow,
//! is absent for the same reason a genuinely repeating one is: storing several
//! values in a scalar column keeps the first and drops the rest.
/// (canonical URL, base) pairs of the single-valued parameters, sorted for
/// binary search.
static SINGLE_VALUED: [(&str, &str); {count}] = [
{entries}];
/// Whether the parameter `url` produces at most one index value for a
/// resource of type `resource_type` — as a parameter of that type, or of
/// every type (`Resource`, `DomainResource`).
///
/// Unknown pairs answer `false`, which is the safe direction: a caller that
/// treats an unclassified parameter as multi valued is slower, one that treats
/// it as single loses data.
#[must_use]
pub fn is_single_valued(url: &str, resource_type: &str) -> bool {{
let listed = |base: &str| {{
SINGLE_VALUED
.binary_search_by(|(u, b)| (*u, *b).cmp(&(url, base)))
.is_ok()
}};
listed(resource_type) || listed("Resource") || listed("DomainResource")
}}
"#,
count = pairs.len(),
entries = entries,
)
}
#[cfg(test)]
mod tests {
use super::*;
use haste_fhir_model::r4::generated::resources::{Bundle, Resource, SearchParameter};
use std::sync::LazyLock;
fn definitions_from(json: &str) -> Vec<StructureDefinition> {
serde_json::from_str::<Bundle>(json)
.expect("bundle parses")
.entry
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.resource)
.filter_map(|r| match *r {
Resource::StructureDefinition(sd) => Some(sd),
_ => None,
})
.collect()
}
static DEFINITIONS: LazyLock<Vec<StructureDefinition>> = LazyLock::new(|| {
let mut all = definitions_from(include_str!(
"../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-resources.min.json"
));
all.extend(definitions_from(include_str!(
"../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-types.min.json"
)));
all
});
static SEARCH_PARAMETERS: LazyLock<Vec<SearchParameter>> = LazyLock::new(|| {
serde_json::from_str::<Bundle>(include_str!(
"../../../../artifacts/r4/hl7-core/definitions/hl7/search-parameters.min.json"
))
.expect("bundle parses")
.entry
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.resource)
.filter_map(|r| match *r {
Resource::SearchParameter(sp) => Some(sp),
_ => None,
})
.collect()
});
const SINGLE_PAIRS_FLOOR: usize = 780;
fn index() -> SnapshotIndex<'static> {
SnapshotIndex::new(DEFINITIONS.iter())
}
fn resolved(expression: &str) -> ResolvedPath {
match analyze_path(&index(), expression) {
PathAnalysis::Resolved(resolved) => resolved,
other => panic!("{expression} did not resolve: {other:?}"),
}
}
fn types(resolved: &ResolvedPath) -> Vec<&str> {
resolved.leaf_types.iter().map(String::as_str).collect()
}
#[test]
fn a_singular_element_does_not_repeat() {
let birth_date = resolved("Patient.birthDate");
assert!(!birth_date.repeats);
assert_eq!(types(&birth_date), ["date"]);
}
#[test]
fn a_repeating_element_anywhere_on_the_path_repeats() {
assert!(resolved("Patient.name.family").repeats);
assert!(resolved("Patient.name").repeats);
}
#[test]
fn the_walk_crosses_into_complex_types() {
assert_eq!(types(&resolved("Patient.name.family")), ["string"]);
assert!(resolved("Patient.contact.name.family").repeats);
}
#[test]
fn a_singular_codeable_concept_still_reports_its_type() {
let code = resolved("Observation.code");
assert!(!code.repeats, "Observation.code is 1..1");
assert_eq!(types(&code), ["CodeableConcept"]);
}
#[test]
fn a_choice_element_reports_all_its_types() {
let effective = resolved("Observation.effective");
assert!(!effective.repeats);
assert!(types(&effective).contains(&"Timing"));
assert!(types(&effective).contains(&"dateTime"));
}
#[test]
fn a_singular_reference_resolves() {
let subject = resolved("Observation.subject");
assert!(!subject.repeats);
assert_eq!(types(&subject), ["Reference"]);
}
#[test]
fn content_references_are_followed() {
assert!(resolved("Questionnaire.item.item.text").repeats);
}
#[test]
fn an_unknown_segment_is_reported_not_guessed() {
assert_eq!(
analyze_path(&index(), "Patient.notAnElement"),
PathAnalysis::Unresolved {
reached: "Patient".to_string(),
segment: "notAnElement".to_string(),
}
);
}
#[test]
fn expressions_needing_the_engine_are_not_plain_paths() {
for expression in [
"Patient.name.where(use='official')",
"Patient.deceased.ofType(dateTime)",
"(Observation.value as Quantity)",
"Patient.extension[0]",
] {
assert_eq!(
analyze_path(&index(), expression),
PathAnalysis::NotAPlainPath,
"{expression}",
);
}
}
#[test]
fn branches_reduce_to_a_path_and_a_cast() {
let branch = |path: &str, cast: Option<&str>| Branch {
path: path.to_string(),
cast: cast.map(ToString::to_string),
};
assert_eq!(
simplify_branch("Observation.subject.where(resolve() is Patient)"),
Some(branch("Observation.subject", None))
);
assert_eq!(
simplify_branch("(RiskAssessment.occurrence.ofType(dateTime))"),
Some(branch("RiskAssessment.occurrence", Some("dateTime")))
);
assert_eq!(
simplify_branch("(Observation.value as Quantity)"),
Some(branch("Observation.value", Some("Quantity")))
);
assert_eq!(simplify_branch("Patient.extension[0]"), None);
assert_eq!(simplify_branch("Patient.name.first()"), None);
}
#[test]
fn the_union_splits_only_at_the_top_level() {
assert_eq!(
split_union("A.b.where(c | d) | E.f"),
["A.b.where(c | d)", "E.f"]
);
}
fn single(expression: &str, base: &str) -> bool {
is_single_valued_for(&index(), expression, base)
}
#[test]
fn a_union_is_judged_per_resource_type() {
let birthdate = "Patient.birthDate | Person.birthDate | RelatedPerson.birthDate";
assert!(single(birthdate, "Patient"));
assert!(single(birthdate, "Person"));
let encounter = "DocumentReference.context.encounter | Observation.encounter";
assert!(!single(encounter, "DocumentReference"));
assert!(single(encounter, "Observation"));
}
#[test]
fn a_filtered_branch_is_as_single_as_its_path() {
assert!(single(
"AllergyIntolerance.patient | Observation.subject.where(resolve() is Patient)",
"Observation"
));
assert!(single(
"AllergyIntolerance.patient | Observation.subject.where(resolve() is Patient)",
"AllergyIntolerance"
));
}
#[test]
fn a_choice_is_single_only_if_none_of_its_types_fans_out() {
assert!(!single("Observation.effective", "Observation"));
assert!(single(
"(RiskAssessment.occurrence.ofType(dateTime))",
"RiskAssessment"
));
assert!(single("Encounter.period", "Encounter"));
}
#[test]
fn branches_for_one_type_must_select_the_same_element() {
assert!(single(
"(Observation.value as Quantity) | (Observation.value as SampledData)",
"Observation"
));
assert!(!single(
"Observation.issued | Observation.effective",
"Observation"
));
}
#[test]
fn an_unreducible_branch_only_affects_its_type() {
let expression = "Patient.extension[0] | Observation.subject";
assert!(!single(expression, "Patient"));
assert!(single(expression, "Observation"));
}
#[test]
fn a_type_the_parameter_has_no_branch_for_is_not_single() {
assert!(!single("Observation.subject", "Condition"));
}
#[test]
fn fanning_out_types_are_not_single_valued() {
assert!(!single("Observation.code", "Observation"));
assert!(single("Observation.subject", "Observation"));
assert!(single("Patient.birthDate", "Patient"));
}
#[test]
fn the_clinical_parameters_classify_per_type() {
let expression = |id: &str| {
SEARCH_PARAMETERS
.iter()
.find(|p| p.id.as_deref() == Some(id))
.and_then(|p| p.expression.as_ref())
.and_then(|e| e.value.clone())
.unwrap_or_else(|| panic!("{id} has an expression"))
};
let patient = expression("clinical-patient");
for base in [
"Observation",
"Condition",
"Encounter",
"Procedure",
"AllergyIntolerance",
] {
assert!(single(&patient, base), "clinical-patient on {base}");
}
let encounter = expression("clinical-encounter");
assert!(single(&encounter, "Observation"));
assert!(!single(&encounter, "DocumentReference"));
let date = expression("clinical-date");
assert!(single(&date, "Encounter"));
assert!(
!single(&date, "Observation"),
"effective[x] may be a Timing"
);
let code = expression("clinical-code");
assert!(!single(&code, "Observation"), "a CodeableConcept fans out");
}
#[test]
fn the_base_corpus_classifies_stably() {
let index = index();
let (mut pairs, mut single) = (0, 0);
for parameter in SEARCH_PARAMETERS.iter() {
let Some(expression) = parameter
.expression
.as_ref()
.and_then(|e| e.value.as_deref())
else {
continue;
};
for base in parameter.base.iter().filter_map(|b| b.as_str()) {
pairs += 1;
if is_single_valued_for(&index, expression, base) {
single += 1;
}
}
}
println!("single={single} of {pairs} (parameter, type) pairs");
assert!(
single >= SINGLE_PAIRS_FLOOR,
"single pairs shrank to {single}, which shrinks the scalar-column win",
);
}
}