use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::core::BackendCapability;
use crate::types::{IncludeType, SearchModifier, SearchParamType, SearchParameter, SearchQuery};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QueryFeature {
BasicSearch,
IdLookup,
StringSearch,
TokenSearch,
DateSearch,
NumberSearch,
QuantitySearch,
ReferenceSearch,
UriSearch,
CompositeSearch,
ChainedSearch,
ReverseChaining,
FullTextSearch,
TerminologySearch,
Include,
Revinclude,
IterateInclude,
Sorting,
CursorPagination,
OffsetPagination,
TotalCount,
Summary,
}
impl QueryFeature {
pub fn required_capability(&self) -> Option<BackendCapability> {
match self {
QueryFeature::BasicSearch
| QueryFeature::IdLookup
| QueryFeature::StringSearch
| QueryFeature::TokenSearch
| QueryFeature::ReferenceSearch
| QueryFeature::UriSearch
| QueryFeature::CompositeSearch => Some(BackendCapability::BasicSearch),
QueryFeature::DateSearch => Some(BackendCapability::DateSearch),
QueryFeature::NumberSearch | QueryFeature::QuantitySearch => {
Some(BackendCapability::QuantitySearch)
}
QueryFeature::ChainedSearch => Some(BackendCapability::ChainedSearch),
QueryFeature::ReverseChaining => Some(BackendCapability::ReverseChaining),
QueryFeature::FullTextSearch => Some(BackendCapability::FullTextSearch),
QueryFeature::TerminologySearch => Some(BackendCapability::TerminologySearch),
QueryFeature::Include | QueryFeature::IterateInclude => {
Some(BackendCapability::Include)
}
QueryFeature::Revinclude => Some(BackendCapability::Revinclude),
QueryFeature::Sorting => Some(BackendCapability::Sorting),
QueryFeature::CursorPagination => Some(BackendCapability::CursorPagination),
QueryFeature::OffsetPagination => Some(BackendCapability::OffsetPagination),
QueryFeature::TotalCount | QueryFeature::Summary => None,
}
}
pub fn prefers_specialized_backend(&self) -> bool {
matches!(
self,
QueryFeature::ChainedSearch
| QueryFeature::ReverseChaining
| QueryFeature::FullTextSearch
| QueryFeature::TerminologySearch
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TerminologyOp {
Above,
Below,
In,
NotIn,
}
#[derive(Debug, Clone)]
pub struct QueryAnalysis {
pub features: HashSet<QueryFeature>,
pub required_capabilities: HashSet<BackendCapability>,
pub complexity_score: u8,
pub feature_params: HashMap<QueryFeature, Vec<SearchParameter>>,
pub splittable: bool,
pub specialized_features: HashSet<QueryFeature>,
pub terminology_ops: Vec<(String, TerminologyOp)>,
}
impl QueryAnalysis {
pub fn empty() -> Self {
Self {
features: HashSet::new(),
required_capabilities: HashSet::new(),
complexity_score: 1,
feature_params: HashMap::new(),
splittable: true,
specialized_features: HashSet::new(),
terminology_ops: Vec::new(),
}
}
pub fn has_advanced_features(&self) -> bool {
self.features
.iter()
.any(|f| f.prefers_specialized_backend())
}
pub fn has_chaining(&self) -> bool {
self.features.contains(&QueryFeature::ChainedSearch)
|| self.features.contains(&QueryFeature::ReverseChaining)
}
pub fn has_fulltext(&self) -> bool {
self.features.contains(&QueryFeature::FullTextSearch)
}
pub fn has_terminology(&self) -> bool {
self.features.contains(&QueryFeature::TerminologySearch)
}
pub fn has_includes(&self) -> bool {
self.features.contains(&QueryFeature::Include)
|| self.features.contains(&QueryFeature::Revinclude)
}
}
#[derive(Debug, Clone, Default)]
pub struct QueryAnalyzer {
_custom_patterns: Vec<()>,
}
impl QueryAnalyzer {
pub fn new() -> Self {
Self::default()
}
pub fn analyze(&self, query: &SearchQuery) -> QueryAnalysis {
let mut analysis = QueryAnalysis::empty();
if !query.parameters.is_empty() || !query.includes.is_empty() {
analysis.features.insert(QueryFeature::BasicSearch);
}
for param in &query.parameters {
self.analyze_parameter(param, &mut analysis);
}
for reverse_chain in &query.reverse_chains {
analysis.features.insert(QueryFeature::ReverseChaining);
analysis
.specialized_features
.insert(QueryFeature::ReverseChaining);
analysis
.feature_params
.entry(QueryFeature::ReverseChaining)
.or_default()
.push(SearchParameter {
name: format!(
"_has:{}:{}:{}",
reverse_chain.source_type,
reverse_chain.reference_param,
reverse_chain.search_param
),
param_type: SearchParamType::Special,
modifier: None,
values: reverse_chain.value.clone().into_iter().collect(),
chain: vec![],
components: vec![],
});
}
for include in &query.includes {
match include.include_type {
IncludeType::Include => {
if include.iterate {
analysis.features.insert(QueryFeature::IterateInclude);
} else {
analysis.features.insert(QueryFeature::Include);
}
}
IncludeType::Revinclude => {
analysis.features.insert(QueryFeature::Revinclude);
}
}
}
if !query.sort.is_empty() {
analysis.features.insert(QueryFeature::Sorting);
}
if query.cursor.is_some() {
analysis.features.insert(QueryFeature::CursorPagination);
} else if query.offset.is_some() {
analysis.features.insert(QueryFeature::OffsetPagination);
}
if query.total.is_some() {
analysis.features.insert(QueryFeature::TotalCount);
}
if query.summary.is_some() {
analysis.features.insert(QueryFeature::Summary);
}
for feature in &analysis.features {
if let Some(cap) = feature.required_capability() {
analysis.required_capabilities.insert(cap);
}
}
analysis.complexity_score = self.calculate_complexity(&analysis);
analysis.splittable = self.is_splittable(&analysis);
analysis
}
fn analyze_parameter(&self, param: &SearchParameter, analysis: &mut QueryAnalysis) {
if param.name == "_id" {
analysis.features.insert(QueryFeature::IdLookup);
return;
}
if param.name == "_text" || param.name == "_content" {
analysis.features.insert(QueryFeature::FullTextSearch);
analysis
.specialized_features
.insert(QueryFeature::FullTextSearch);
analysis
.feature_params
.entry(QueryFeature::FullTextSearch)
.or_default()
.push(param.clone());
return;
}
if !param.chain.is_empty() {
analysis.features.insert(QueryFeature::ChainedSearch);
analysis
.specialized_features
.insert(QueryFeature::ChainedSearch);
analysis
.feature_params
.entry(QueryFeature::ChainedSearch)
.or_default()
.push(param.clone());
}
if let Some(ref modifier) = param.modifier {
if let Some(term_op) = self.parse_terminology_modifier(modifier) {
analysis.features.insert(QueryFeature::TerminologySearch);
analysis
.specialized_features
.insert(QueryFeature::TerminologySearch);
analysis.terminology_ops.push((param.name.clone(), term_op));
analysis
.feature_params
.entry(QueryFeature::TerminologySearch)
.or_default()
.push(param.clone());
}
}
if let Some(SearchModifier::Text) = param.modifier {
if param.param_type == SearchParamType::Token {
analysis.features.insert(QueryFeature::FullTextSearch);
}
}
let type_feature = match param.param_type {
SearchParamType::String => QueryFeature::StringSearch,
SearchParamType::Token => QueryFeature::TokenSearch,
SearchParamType::Date => QueryFeature::DateSearch,
SearchParamType::Number => QueryFeature::NumberSearch,
SearchParamType::Quantity => QueryFeature::QuantitySearch,
SearchParamType::Reference => QueryFeature::ReferenceSearch,
SearchParamType::Uri => QueryFeature::UriSearch,
SearchParamType::Composite => QueryFeature::CompositeSearch,
SearchParamType::Special => QueryFeature::BasicSearch,
};
analysis.features.insert(type_feature);
if param.chain.is_empty()
&& !analysis
.specialized_features
.contains(&QueryFeature::TerminologySearch)
{
analysis
.feature_params
.entry(QueryFeature::BasicSearch)
.or_default()
.push(param.clone());
}
}
fn parse_terminology_modifier(&self, modifier: &SearchModifier) -> Option<TerminologyOp> {
match modifier {
SearchModifier::Above => Some(TerminologyOp::Above),
SearchModifier::Below => Some(TerminologyOp::Below),
SearchModifier::In => Some(TerminologyOp::In),
SearchModifier::NotIn => Some(TerminologyOp::NotIn),
_ => None,
}
}
fn calculate_complexity(&self, analysis: &QueryAnalysis) -> u8 {
let mut score = 1u8;
if analysis.has_chaining() {
score = score.saturating_add(2);
}
if analysis.has_fulltext() {
score = score.saturating_add(1);
}
if analysis.has_terminology() {
score = score.saturating_add(2);
}
if analysis.has_includes() {
score = score.saturating_add(1);
}
let feature_count = analysis.features.len();
if feature_count > 5 {
score = score.saturating_add(1);
}
if feature_count > 8 {
score = score.saturating_add(1);
}
if analysis.features.contains(&QueryFeature::ReverseChaining) {
score = score.saturating_add(1);
}
score.min(10)
}
fn is_splittable(&self, _analysis: &QueryAnalysis) -> bool {
true
}
pub fn features_for_param(&self, param: &SearchParameter) -> HashSet<QueryFeature> {
let mut analysis = QueryAnalysis::empty();
self.analyze_parameter(param, &mut analysis);
analysis.features
}
}
pub fn features_to_capabilities(features: &HashSet<QueryFeature>) -> HashSet<BackendCapability> {
features
.iter()
.filter_map(|f| f.required_capability())
.collect()
}
pub fn detect_query_features(query: &SearchQuery) -> HashSet<QueryFeature> {
QueryAnalyzer::new().analyze(query).features
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ChainedParameter, IncludeDirective, SearchModifier, SearchValue, SortDirective,
};
#[test]
fn test_detect_basic_search() {
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "name".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::string("Smith")],
chain: vec![],
components: vec![],
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::BasicSearch));
assert!(features.contains(&QueryFeature::StringSearch));
}
#[test]
fn test_detect_chained_search() {
let query = SearchQuery::new("Observation").with_parameter(SearchParameter {
name: "name".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::string("Smith")],
chain: vec![ChainedParameter {
reference_param: "subject".to_string(),
target_type: Some("Patient".to_string()),
target_param: "name".to_string(),
}],
components: vec![],
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::ChainedSearch));
}
#[test]
fn test_detect_fulltext_search() {
let query = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "_text".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::string("cardiac")],
chain: vec![],
components: vec![],
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::FullTextSearch));
}
#[test]
fn test_detect_terminology_search() {
let query = SearchQuery::new("Observation").with_parameter(SearchParameter {
name: "code".to_string(),
param_type: SearchParamType::Token,
modifier: Some(SearchModifier::Below),
values: vec![SearchValue::token(Some("http://loinc.org"), "8867-4")],
chain: vec![],
components: vec![],
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::TerminologySearch));
}
#[test]
fn test_detect_terminology_search_above() {
let query = SearchQuery::new("Observation").with_parameter(SearchParameter {
name: "code".to_string(),
param_type: SearchParamType::Token,
modifier: Some(SearchModifier::Above),
values: vec![SearchValue::token(Some("http://loinc.org"), "8867-4")],
chain: vec![],
components: vec![],
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::TerminologySearch));
}
#[test]
fn test_detect_include() {
let query = SearchQuery::new("Observation").with_include(IncludeDirective {
include_type: IncludeType::Include,
source_type: "Observation".to_string(),
search_param: "patient".to_string(),
target_type: Some("Patient".to_string()),
iterate: false,
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::Include));
}
#[test]
fn test_detect_revinclude() {
let query = SearchQuery::new("Patient").with_include(IncludeDirective {
include_type: IncludeType::Revinclude,
source_type: "Observation".to_string(),
search_param: "subject".to_string(),
target_type: Some("Patient".to_string()),
iterate: false,
});
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::Revinclude));
}
#[test]
fn test_detect_sorting() {
let query = SearchQuery::new("Patient").with_sort(SortDirective::parse("-_lastUpdated"));
let features = detect_query_features(&query);
assert!(features.contains(&QueryFeature::Sorting));
}
#[test]
fn test_complexity_score() {
let analyzer = QueryAnalyzer::new();
let simple = SearchQuery::new("Patient").with_parameter(SearchParameter {
name: "_id".to_string(),
param_type: SearchParamType::Token,
modifier: None,
values: vec![SearchValue::eq("123")],
chain: vec![],
components: vec![],
});
let simple_analysis = analyzer.analyze(&simple);
assert!(simple_analysis.complexity_score <= 3);
let complex = SearchQuery::new("Observation")
.with_parameter(SearchParameter {
name: "name".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::string("Smith")],
chain: vec![ChainedParameter {
reference_param: "subject".to_string(),
target_type: Some("Patient".to_string()),
target_param: "name".to_string(),
}],
components: vec![],
})
.with_parameter(SearchParameter {
name: "_text".to_string(),
param_type: SearchParamType::String,
modifier: None,
values: vec![SearchValue::string("cardiac")],
chain: vec![],
components: vec![],
})
.with_parameter(SearchParameter {
name: "code".to_string(),
param_type: SearchParamType::Token,
modifier: Some(SearchModifier::Below),
values: vec![SearchValue::token(Some("http://loinc.org"), "8867-4")],
chain: vec![],
components: vec![],
});
let complex_analysis = analyzer.analyze(&complex);
assert!(
complex_analysis.complexity_score >= 5,
"Expected complexity >= 5, got {}",
complex_analysis.complexity_score
);
}
#[test]
fn test_features_to_capabilities() {
let features = HashSet::from([
QueryFeature::BasicSearch,
QueryFeature::ChainedSearch,
QueryFeature::FullTextSearch,
]);
let caps = features_to_capabilities(&features);
assert!(caps.contains(&BackendCapability::BasicSearch));
assert!(caps.contains(&BackendCapability::ChainedSearch));
assert!(caps.contains(&BackendCapability::FullTextSearch));
}
}