#![allow(missing_docs)]
use std::sync::Arc;
use parking_lot::RwLock;
use crate::error::{BackendError, StorageResult};
use crate::search::SearchParameterRegistry;
use crate::types::{ChainConfig, ReverseChainedParameter, SearchParamType, SearchValue};
use super::query_builder::{SqlFragment, SqlParam};
#[derive(Debug, Clone)]
pub struct ChainLink {
pub reference_param: String,
pub target_type: String,
}
#[derive(Debug, Clone)]
pub struct ParsedChain {
pub links: Vec<ChainLink>,
pub terminal_param: String,
pub terminal_type: SearchParamType,
}
#[derive(Debug, Clone)]
pub enum ChainError {
MaxDepthExceeded { depth: usize, max: usize },
UnknownReferenceParam {
resource_type: String,
param: String,
},
AmbiguousTargetType {
resource_type: String,
param: String,
},
UnknownTerminalParam {
resource_type: String,
param: String,
},
EmptyChain,
InvalidSyntax { message: String },
}
impl std::fmt::Display for ChainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChainError::MaxDepthExceeded { depth, max } => {
write!(
f,
"Chain depth {} exceeds maximum allowed depth {}",
depth, max
)
}
ChainError::UnknownReferenceParam {
resource_type,
param,
} => {
write!(
f,
"Unknown reference parameter '{}' for resource type '{}'",
param, resource_type
)
}
ChainError::AmbiguousTargetType {
resource_type,
param,
} => {
write!(
f,
"Ambiguous target type for parameter '{}' on '{}'. Use type modifier.",
param, resource_type
)
}
ChainError::UnknownTerminalParam {
resource_type,
param,
} => {
write!(
f,
"Unknown terminal parameter '{}' for resource type '{}'",
param, resource_type
)
}
ChainError::EmptyChain => write!(f, "Empty chain"),
ChainError::InvalidSyntax { message } => write!(f, "Invalid chain syntax: {}", message),
}
}
}
impl From<ChainError> for BackendError {
fn from(e: ChainError) -> Self {
BackendError::Internal {
backend_name: "sqlite".to_string(),
message: e.to_string(),
source: None,
}
}
}
pub struct ChainQueryBuilder {
tenant_id: String,
base_type: String,
registry: Arc<RwLock<SearchParameterRegistry>>,
config: ChainConfig,
param_offset: usize,
}
impl ChainQueryBuilder {
pub fn new(
tenant_id: impl Into<String>,
base_type: impl Into<String>,
registry: Arc<RwLock<SearchParameterRegistry>>,
) -> Self {
Self {
tenant_id: tenant_id.into(),
base_type: base_type.into(),
registry,
config: ChainConfig::default(),
param_offset: 2, }
}
pub fn with_config(mut self, config: ChainConfig) -> Self {
self.config = config;
self
}
pub fn with_param_offset(mut self, offset: usize) -> Self {
self.param_offset = offset;
self
}
pub fn parse_chain(&self, chain_str: &str) -> Result<ParsedChain, ChainError> {
if chain_str.is_empty() {
return Err(ChainError::EmptyChain);
}
let parts: Vec<&str> = chain_str.split('.').collect();
if parts.len() < 2 {
return Err(ChainError::InvalidSyntax {
message: "Chain must have at least two parts (reference.param)".to_string(),
});
}
let chain_depth = parts.len() - 1; if !self.config.validate_forward_depth(chain_depth) {
return Err(ChainError::MaxDepthExceeded {
depth: chain_depth,
max: self.config.max_forward_depth,
});
}
let mut links = Vec::new();
let mut current_type = self.base_type.clone();
for part in parts.iter().take(parts.len() - 1) {
let (ref_param, explicit_type) = self.parse_chain_part(part);
let target_type = self.resolve_target_type(¤t_type, &ref_param, explicit_type)?;
links.push(ChainLink {
reference_param: ref_param,
target_type: target_type.clone(),
});
current_type = target_type;
}
let terminal_param = parts[parts.len() - 1].to_string();
let terminal_type = self.resolve_terminal_type(¤t_type, &terminal_param)?;
Ok(ParsedChain {
links,
terminal_param,
terminal_type,
})
}
fn parse_chain_part(&self, part: &str) -> (String, Option<String>) {
if let Some((param, type_mod)) = part.split_once(':') {
(param.to_string(), Some(type_mod.to_string()))
} else {
(part.to_string(), None)
}
}
fn resolve_target_type(
&self,
resource_type: &str,
ref_param: &str,
explicit_type: Option<String>,
) -> Result<String, ChainError> {
if let Some(t) = explicit_type {
return Ok(t);
}
let registry = self.registry.read();
if let Some(param_def) = registry.get_param(resource_type, ref_param) {
if param_def.param_type != SearchParamType::Reference {
return Err(ChainError::UnknownReferenceParam {
resource_type: resource_type.to_string(),
param: ref_param.to_string(),
});
}
if let Some(ref targets) = param_def.target {
if targets.len() == 1 {
return Ok(targets[0].clone());
} else if targets.is_empty() {
return Ok(self.infer_target_type(ref_param));
} else {
return Ok(self.infer_target_type(ref_param));
}
}
}
Ok(self.infer_target_type(ref_param))
}
fn infer_target_type(&self, ref_param: &str) -> String {
match ref_param {
"patient" | "subject" => "Patient".to_string(),
"practitioner" | "performer" | "requester" | "author" => "Practitioner".to_string(),
"organization" | "managingOrganization" | "custodian" => "Organization".to_string(),
"encounter" | "context" => "Encounter".to_string(),
"location" => "Location".to_string(),
"device" => "Device".to_string(),
"specimen" => "Specimen".to_string(),
"medication" => "Medication".to_string(),
"condition" => "Condition".to_string(),
_ => {
let mut chars = ref_param.chars();
match chars.next() {
Some(c) => c.to_uppercase().chain(chars).collect(),
None => ref_param.to_string(),
}
}
}
}
fn resolve_terminal_type(
&self,
resource_type: &str,
param_name: &str,
) -> Result<SearchParamType, ChainError> {
let registry = self.registry.read();
if let Some(param_def) = registry.get_param(resource_type, param_name) {
Ok(param_def.param_type)
} else {
match param_name {
"_id" | "id" => Ok(SearchParamType::Token),
"name" | "family" | "given" | "text" | "display" => Ok(SearchParamType::String),
"identifier" | "code" | "status" | "type" | "category" => {
Ok(SearchParamType::Token)
}
_ => Err(ChainError::UnknownTerminalParam {
resource_type: resource_type.to_string(),
param: param_name.to_string(),
}),
}
}
}
pub fn build_forward_chain_sql(
&self,
chain: &ParsedChain,
value: &SearchValue,
) -> StorageResult<SqlFragment> {
if chain.links.is_empty() {
return Err(BackendError::Internal {
backend_name: "sqlite".to_string(),
message: "Empty chain".to_string(),
source: None,
}
.into());
}
let param_num = self.param_offset + 1;
let (terminal_sql, terminal_param) =
self.build_terminal_condition(chain, value, param_num)?;
let terminal_type = &chain.links[chain.links.len() - 1].target_type;
let mut current_sql = format!(
"SELECT '{}/{}' || si{}.resource_id FROM search_index si{} \
WHERE si{}.tenant_id = ?1 AND si{}.resource_type = '{}' \
AND si{}.param_name = '{}' AND {}",
terminal_type,
"", chain.links.len(),
chain.links.len(),
chain.links.len(),
chain.links.len(),
terminal_type,
chain.links.len(),
chain.terminal_param,
terminal_sql
);
for (i, link) in chain.links.iter().enumerate().rev() {
let link_num = i + 1;
let current_type = if i == 0 {
&self.base_type
} else {
&chain.links[i - 1].target_type
};
if i == 0 {
current_sql = format!(
"SELECT si{link_num}.resource_id FROM search_index si{link_num} \
WHERE si{link_num}.tenant_id = ?1 AND si{link_num}.resource_type = '{current_type}' \
AND si{link_num}.param_name = '{ref_param}' \
AND si{link_num}.value_reference IN ({inner})",
link_num = link_num,
current_type = current_type,
ref_param = link.reference_param,
inner = current_sql
);
} else {
current_sql = format!(
"SELECT '{current_type}/' || si{link_num}.resource_id FROM search_index si{link_num} \
WHERE si{link_num}.tenant_id = ?1 AND si{link_num}.resource_type = '{current_type}' \
AND si{link_num}.param_name = '{ref_param}' \
AND si{link_num}.value_reference IN ({inner})",
current_type = current_type,
link_num = link_num,
ref_param = link.reference_param,
inner = current_sql
);
}
}
let final_sql = format!("r.id IN ({})", current_sql);
Ok(SqlFragment::with_params(final_sql, vec![terminal_param]))
}
fn build_terminal_condition(
&self,
chain: &ParsedChain,
value: &SearchValue,
param_num: usize,
) -> StorageResult<(String, SqlParam)> {
let alias_num = chain.links.len();
let alias = format!("si{}", alias_num);
let (condition, param) = match chain.terminal_type {
SearchParamType::String => {
let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
(
format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
SqlParam::String(format!("%{}%", escaped)),
)
}
SearchParamType::Token => {
if let Some((system, code)) = value.value.split_once('|') {
if system.is_empty() {
(
format!(
"({}.value_token_system IS NULL OR {}.value_token_system = '') \
AND {}.value_token_code = ?{}",
alias, alias, alias, param_num
),
SqlParam::String(code.to_string()),
)
} else {
(
format!(
"{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
alias,
system.replace('\'', "''"),
alias,
param_num
),
SqlParam::String(code.to_string()),
)
}
} else {
(
format!("{}.value_token_code = ?{}", alias, param_num),
SqlParam::String(value.value.clone()),
)
}
}
SearchParamType::Reference => (
format!("{}.value_reference LIKE ?{}", alias, param_num),
SqlParam::String(format!("%{}%", value.value)),
),
SearchParamType::Date => {
let date_col = format!("{}.value_date", alias);
build_date_condition(&date_col, value, param_num)
}
SearchParamType::Number => {
let num_col = format!("{}.value_number", alias);
build_number_condition(&num_col, value, param_num)
}
SearchParamType::Quantity => {
let qty_col = format!("{}.value_quantity_value", alias);
build_number_condition(&qty_col, value, param_num)
}
SearchParamType::Uri => (
format!("{}.value_uri = ?{}", alias, param_num),
SqlParam::String(value.value.clone()),
),
_ => (
format!("{}.value_string LIKE ?{}", alias, param_num),
SqlParam::String(format!("%{}%", value.value)),
),
};
Ok((condition, param))
}
pub fn build_reverse_chain_sql(
&self,
reverse_chain: &ReverseChainedParameter,
) -> StorageResult<SqlFragment> {
let depth = reverse_chain.depth();
if !self.config.validate_reverse_depth(depth) {
return Err(BackendError::Internal {
backend_name: "sqlite".to_string(),
message: format!(
"Reverse chain depth {} exceeds maximum {}",
depth, self.config.max_reverse_depth
),
source: None,
}
.into());
}
let param_num = self.param_offset + 1;
let (sql, params) = self.build_reverse_chain_recursive(reverse_chain, 1, param_num)?;
Ok(SqlFragment::with_params(
format!("r.id IN ({})", sql),
params,
))
}
fn build_reverse_chain_recursive(
&self,
rc: &ReverseChainedParameter,
depth: usize,
param_num: usize,
) -> StorageResult<(String, Vec<SqlParam>)> {
let alias = format!("si{}", depth);
if rc.is_terminal() {
let value = rc.value.as_ref().ok_or_else(|| BackendError::Internal {
backend_name: "sqlite".to_string(),
message: "Terminal reverse chain must have a value".to_string(),
source: None,
})?;
let (search_condition, search_param) = self.build_reverse_terminal_condition(
&rc.source_type,
&rc.search_param,
value,
depth + 1,
param_num,
)?;
let depth2 = depth + 1;
let sql = format!(
"SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
FROM search_index {alias} \
WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{src_type}' \
AND {alias}.param_name = '{ref_param}' \
AND {alias}.value_reference LIKE '{base_type}/%' \
AND {alias}.resource_id IN (\
SELECT si{depth2}.resource_id FROM search_index si{depth2} \
WHERE si{depth2}.tenant_id = ?1 AND si{depth2}.resource_type = '{src_type}' \
AND si{depth2}.param_name = '{search_param_name}' AND {search_condition}\
)",
alias = alias,
src_type = rc.source_type,
ref_param = rc.reference_param,
base_type = self.base_type,
depth2 = depth2,
search_param_name = rc.search_param,
search_condition = search_condition,
);
Ok((sql, vec![search_param]))
} else {
let inner = rc.nested.as_ref().ok_or_else(|| BackendError::Internal {
backend_name: "sqlite".to_string(),
message: "Non-terminal reverse chain must have nested chain".to_string(),
source: None,
})?;
let inner_builder = ChainQueryBuilder::new(
&self.tenant_id,
&rc.source_type,
Arc::clone(&self.registry),
)
.with_config(self.config.clone())
.with_param_offset(param_num - 1);
let (inner_sql, inner_params) =
inner_builder.build_reverse_chain_recursive(inner, depth + 1, param_num)?;
let sql = format!(
"SELECT SUBSTR({alias}.value_reference, INSTR({alias}.value_reference, '/') + 1) \
FROM search_index {alias} \
WHERE {alias}.tenant_id = ?1 AND {alias}.resource_type = '{}' \
AND {alias}.param_name = '{}' \
AND {alias}.value_reference LIKE '{}/%' \
AND {alias}.resource_id IN ({inner_sql})",
rc.source_type,
rc.reference_param,
self.base_type,
alias = alias,
);
Ok((sql, inner_params))
}
}
fn build_reverse_terminal_condition(
&self,
resource_type: &str,
param_name: &str,
value: &SearchValue,
depth: usize,
param_num: usize,
) -> StorageResult<(String, SqlParam)> {
let param_type = {
let registry = self.registry.read();
registry
.get_param(resource_type, param_name)
.map(|p| p.param_type)
.unwrap_or_else(|| self.infer_param_type(param_name))
};
let alias = format!("si{}", depth);
let (condition, param) = match param_type {
SearchParamType::String => {
let escaped = value.value.replace('%', "\\%").replace('_', "\\_");
(
format!("{}.value_string LIKE ?{} ESCAPE '\\'", alias, param_num),
SqlParam::String(format!("%{}%", escaped)),
)
}
SearchParamType::Token => {
if let Some((system, code)) = value.value.split_once('|') {
if system.is_empty() {
(
format!(
"({}.value_token_system IS NULL OR {}.value_token_system = '') \
AND {}.value_token_code = ?{}",
alias, alias, alias, param_num
),
SqlParam::String(code.to_string()),
)
} else {
(
format!(
"{}.value_token_system = '{}' AND {}.value_token_code = ?{}",
alias,
system.replace('\'', "''"),
alias,
param_num
),
SqlParam::String(code.to_string()),
)
}
} else {
(
format!("{}.value_token_code = ?{}", alias, param_num),
SqlParam::String(value.value.clone()),
)
}
}
SearchParamType::Reference => (
format!("{}.value_reference LIKE ?{}", alias, param_num),
SqlParam::String(format!("%{}%", value.value)),
),
SearchParamType::Date => {
let date_col = format!("{}.value_date", alias);
build_date_condition(&date_col, value, param_num)
}
SearchParamType::Number => {
let num_col = format!("{}.value_number", alias);
build_number_condition(&num_col, value, param_num)
}
SearchParamType::Quantity => {
let qty_col = format!("{}.value_quantity_value", alias);
build_number_condition(&qty_col, value, param_num)
}
SearchParamType::Uri => (
format!("{}.value_uri = ?{}", alias, param_num),
SqlParam::String(value.value.clone()),
),
_ => (
format!("{}.value_string LIKE ?{}", alias, param_num),
SqlParam::String(format!("%{}%", value.value)),
),
};
Ok((condition, param))
}
fn infer_param_type(&self, param_name: &str) -> SearchParamType {
match param_name {
"name" | "family" | "given" | "text" | "display" | "description" | "address"
| "city" | "state" | "country" => SearchParamType::String,
"identifier" | "code" | "status" | "type" | "category" | "class" | "gender"
| "language" => SearchParamType::Token,
"date" | "birthdate" | "issued" | "effective" | "period" | "authored" => {
SearchParamType::Date
}
"patient" | "subject" | "performer" | "author" | "encounter" | "organization"
| "practitioner" | "location" => SearchParamType::Reference,
"value-quantity" | "dose" | "quantity" => SearchParamType::Quantity,
"length" | "count" | "value" => SearchParamType::Number,
"url" | "source" => SearchParamType::Uri,
_ => SearchParamType::String, }
}
}
fn build_date_condition(column: &str, value: &SearchValue, param_num: usize) -> (String, SqlParam) {
use crate::types::SearchPrefix;
let (op, val) = match value.prefix {
SearchPrefix::Eq => ("=", &value.value),
SearchPrefix::Ne => ("!=", &value.value),
SearchPrefix::Gt => (">", &value.value),
SearchPrefix::Lt => ("<", &value.value),
SearchPrefix::Ge => (">=", &value.value),
SearchPrefix::Le => ("<=", &value.value),
SearchPrefix::Sa => (">", &value.value),
SearchPrefix::Eb => ("<", &value.value),
SearchPrefix::Ap => {
return (
format!("DATE({}) = DATE(?{})", column, param_num),
SqlParam::String(value.value.clone()),
);
}
};
(
format!("{} {} ?{}", column, op, param_num),
SqlParam::String(val.clone()),
)
}
fn build_number_condition(
column: &str,
value: &SearchValue,
param_num: usize,
) -> (String, SqlParam) {
use crate::types::SearchPrefix;
let num_value = value.value.parse::<f64>().unwrap_or(0.0);
let (op, val) = match value.prefix {
SearchPrefix::Eq => ("=", num_value),
SearchPrefix::Ne => ("!=", num_value),
SearchPrefix::Gt => (">", num_value),
SearchPrefix::Lt => ("<", num_value),
SearchPrefix::Ge => (">=", num_value),
SearchPrefix::Le => ("<=", num_value),
SearchPrefix::Sa => (">", num_value),
SearchPrefix::Eb => ("<", num_value),
SearchPrefix::Ap => {
let lower = num_value * 0.9;
let upper = num_value * 1.1;
return (
format!("{} BETWEEN {} AND {}", column, lower, upper),
SqlParam::Float(num_value),
);
}
};
(
format!("{} {} ?{}", column, op, param_num),
SqlParam::Float(val),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::SearchParameterDefinition;
fn create_test_registry() -> Arc<RwLock<SearchParameterRegistry>> {
let mut registry = SearchParameterRegistry::new();
let patient_subject = SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Observation-subject",
"subject",
SearchParamType::Reference,
"Observation.subject",
)
.with_base(vec!["Observation"])
.with_targets(vec!["Patient"]);
let patient_org = SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Patient-organization",
"organization",
SearchParamType::Reference,
"Patient.managingOrganization",
)
.with_base(vec!["Patient"])
.with_targets(vec!["Organization"]);
let org_name = SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Organization-name",
"name",
SearchParamType::String,
"Organization.name",
)
.with_base(vec!["Organization"]);
let patient_name = SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Patient-name",
"name",
SearchParamType::String,
"Patient.name",
)
.with_base(vec!["Patient"]);
let obs_code = SearchParameterDefinition::new(
"http://hl7.org/fhir/SearchParameter/Observation-code",
"code",
SearchParamType::Token,
"Observation.code",
)
.with_base(vec!["Observation"]);
registry.register(patient_subject).unwrap();
registry.register(patient_org).unwrap();
registry.register(org_name).unwrap();
registry.register(patient_name).unwrap();
registry.register(obs_code).unwrap();
Arc::new(RwLock::new(registry))
}
#[test]
fn test_parse_simple_chain() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
let result = builder.parse_chain("subject.name");
assert!(result.is_ok());
let chain = result.unwrap();
assert_eq!(chain.links.len(), 1);
assert_eq!(chain.links[0].reference_param, "subject");
assert_eq!(chain.links[0].target_type, "Patient");
assert_eq!(chain.terminal_param, "name");
assert_eq!(chain.terminal_type, SearchParamType::String);
}
#[test]
fn test_parse_multi_level_chain() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
let result = builder.parse_chain("subject.organization.name");
assert!(result.is_ok());
let chain = result.unwrap();
assert_eq!(chain.links.len(), 2);
assert_eq!(chain.links[0].reference_param, "subject");
assert_eq!(chain.links[0].target_type, "Patient");
assert_eq!(chain.links[1].reference_param, "organization");
assert_eq!(chain.links[1].target_type, "Organization");
assert_eq!(chain.terminal_param, "name");
}
#[test]
fn test_parse_chain_with_type_modifier() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
let result = builder.parse_chain("subject:Patient.name");
assert!(result.is_ok());
let chain = result.unwrap();
assert_eq!(chain.links[0].target_type, "Patient");
}
#[test]
fn test_max_depth_exceeded() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Observation", registry)
.with_config(ChainConfig::new(2, 2));
let result = builder.parse_chain("a.b.c.d"); assert!(matches!(
result,
Err(ChainError::MaxDepthExceeded { depth: 3, max: 2 })
));
}
#[test]
fn test_build_forward_chain_sql() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Observation", registry);
let chain = builder.parse_chain("subject.name").unwrap();
let value = SearchValue::eq("Smith");
let result = builder.build_forward_chain_sql(&chain, &value);
assert!(result.is_ok());
let fragment = result.unwrap();
assert!(fragment.sql.contains("r.id IN"));
assert!(fragment.sql.contains("search_index"));
assert!(fragment.sql.contains("subject"));
assert!(fragment.sql.contains("name"));
}
#[test]
fn test_build_reverse_chain_sql() {
let registry = create_test_registry();
let builder = ChainQueryBuilder::new("tenant1", "Patient", registry);
let rc = ReverseChainedParameter::terminal(
"Observation",
"subject",
"code",
SearchValue::eq("1234-5"),
);
let result = builder.build_reverse_chain_sql(&rc);
assert!(result.is_ok());
let fragment = result.unwrap();
assert!(fragment.sql.contains("r.id IN"));
assert!(fragment.sql.contains("Observation"));
assert!(fragment.sql.contains("subject"));
assert!(fragment.sql.contains("code"));
assert!(fragment.sql.contains("Patient/%"));
}
#[test]
fn test_reverse_chain_depth() {
let inner = ReverseChainedParameter::terminal(
"Provenance",
"target",
"agent",
SearchValue::eq("Practitioner/123"),
);
let outer = ReverseChainedParameter::nested("Observation", "subject", inner);
assert_eq!(outer.depth(), 2);
assert!(!outer.is_terminal());
}
}