Skip to main content

helios_persistence/backends/sqlite/search/parameter_handlers/
composite.rs

1//! Composite parameter SQL handler.
2
3use crate::types::{CompositeSearchComponent, SearchParamType, SearchPrefix, SearchValue};
4
5use super::super::query_builder::SqlFragment;
6use super::{DateHandler, NumberHandler, QuantityHandler, StringHandler, TokenHandler};
7
8/// Handles composite parameter SQL generation.
9///
10/// Composite parameters combine multiple sub-parameters with a `$` separator.
11/// For example, `component-code-value-quantity=http://loinc.org|8480-6$lt60`
12/// combines a token search on code with a quantity search on value.
13pub struct CompositeHandler;
14
15/// Definition of a composite component.
16#[derive(Debug, Clone)]
17pub struct CompositeComponentDef {
18    /// The sub-parameter type.
19    pub param_type: SearchParamType,
20    /// The column name prefix to use for this component.
21    pub column_prefix: String,
22}
23
24impl CompositeHandler {
25    /// Builds SQL for a composite parameter value using CompositeSearchComponent definitions.
26    ///
27    /// This is the primary entry point called from QueryBuilder.
28    /// Since composite parameters need to match all component conditions on the same row,
29    /// we simply combine all conditions with AND. The outer query already filters by
30    /// param_name, so we just need the value conditions.
31    ///
32    /// Note: For true composite group matching (where values must come from the same
33    /// composite instance), we would need the extractor to populate composite_group
34    /// during indexing and use a more complex query. For now, we match all conditions
35    /// which works for simple cases.
36    pub fn build_composite_sql(
37        value: &SearchValue,
38        _param_name: &str,
39        components: &[CompositeSearchComponent],
40        param_offset: usize,
41    ) -> SqlFragment {
42        let composite_value = &value.value;
43        let parts: Vec<&str> = composite_value.split('$').collect();
44
45        if parts.len() != components.len() || components.is_empty() {
46            return SqlFragment::new("1 = 0");
47        }
48
49        let mut component_conditions = Vec::new();
50        let mut all_params = Vec::new();
51        let mut current_offset = param_offset;
52
53        // Build condition for each component
54        for (part, component) in parts.iter().zip(components.iter()) {
55            let component_value = Self::parse_component_value(part);
56            let fragment = Self::build_component_sql_from_type(
57                &component_value,
58                component.param_type,
59                current_offset,
60            );
61
62            if fragment.sql == "1 = 0" {
63                return SqlFragment::new("1 = 0");
64            }
65
66            component_conditions.push(fragment.sql);
67            current_offset += fragment.params.len();
68            all_params.extend(fragment.params);
69        }
70
71        // Combine all component conditions - they must all match
72        // The outer query context already filters by param_name and resource context
73        let conditions_sql = component_conditions.join(" AND ");
74
75        SqlFragment::with_params(format!("({})", conditions_sql), all_params)
76    }
77
78    /// Builds SQL for a composite parameter value.
79    ///
80    /// The value should be in the format "value1$value2$..." where each value
81    /// corresponds to a component defined in the composite parameter.
82    ///
83    /// All components must match on the same search_index row (composite_group).
84    pub fn build_sql(
85        value: &SearchValue,
86        components: &[CompositeComponentDef],
87        param_offset: usize,
88    ) -> SqlFragment {
89        let composite_value = &value.value;
90        let parts: Vec<&str> = composite_value.split('$').collect();
91
92        if parts.len() != components.len() {
93            // Mismatch in component count
94            return SqlFragment::new("1 = 0");
95        }
96
97        let mut conditions = Vec::new();
98        let mut params = Vec::new();
99        let mut current_offset = param_offset;
100
101        for (part, component) in parts.iter().zip(components.iter()) {
102            // Create a SearchValue for this component part
103            let component_value = Self::parse_component_value(part);
104
105            // Generate SQL for this component based on its type
106            let fragment = Self::build_component_sql(&component_value, component, current_offset);
107
108            if fragment.sql == "1 = 0" {
109                // Invalid component value
110                return SqlFragment::new("1 = 0");
111            }
112
113            conditions.push(fragment.sql);
114            current_offset += fragment.params.len();
115            params.extend(fragment.params);
116        }
117
118        // All conditions must match on the same composite_group
119        // We wrap the conditions to ensure they're matched together
120        SqlFragment::with_params(format!("({})", conditions.join(" AND ")), params)
121    }
122
123    /// Builds component SQL from a SearchParamType directly.
124    fn build_component_sql_from_type(
125        value: &SearchValue,
126        param_type: SearchParamType,
127        param_offset: usize,
128    ) -> SqlFragment {
129        match param_type {
130            SearchParamType::Token => TokenHandler::build_sql(value, None, param_offset),
131            SearchParamType::String => StringHandler::build_sql(value, None, param_offset),
132            SearchParamType::Date => DateHandler::build_sql(value, param_offset),
133            SearchParamType::Number => NumberHandler::build_sql(value, param_offset),
134            SearchParamType::Quantity => QuantityHandler::build_sql(value, param_offset),
135            _ => SqlFragment::new("1 = 0"),
136        }
137    }
138
139    /// Parses a component value, extracting any prefix.
140    fn parse_component_value(part: &str) -> SearchValue {
141        // Check for comparison prefixes at the start
142        let prefixes = [
143            ("ne", SearchPrefix::Ne),
144            ("gt", SearchPrefix::Gt),
145            ("lt", SearchPrefix::Lt),
146            ("ge", SearchPrefix::Ge),
147            ("le", SearchPrefix::Le),
148            ("sa", SearchPrefix::Sa),
149            ("eb", SearchPrefix::Eb),
150            ("ap", SearchPrefix::Ap),
151            ("eq", SearchPrefix::Eq),
152        ];
153
154        for (prefix_str, prefix) in prefixes {
155            if let Some(stripped) = part.strip_prefix(prefix_str) {
156                return SearchValue::new(prefix, stripped);
157            }
158        }
159
160        // No prefix found - default to eq
161        SearchValue::new(SearchPrefix::Eq, part)
162    }
163
164    /// Builds SQL for a single component.
165    fn build_component_sql(
166        value: &SearchValue,
167        component: &CompositeComponentDef,
168        param_offset: usize,
169    ) -> SqlFragment {
170        match component.param_type {
171            SearchParamType::Token => {
172                // Use token handler but we may need to adjust column names
173                TokenHandler::build_sql(value, None, param_offset)
174            }
175            SearchParamType::String => StringHandler::build_sql(value, None, param_offset),
176            SearchParamType::Date => DateHandler::build_sql(value, param_offset),
177            SearchParamType::Number => NumberHandler::build_sql(value, param_offset),
178            SearchParamType::Quantity => QuantityHandler::build_sql(value, param_offset),
179            _ => {
180                // Unsupported component type
181                SqlFragment::new("1 = 0")
182            }
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_composite_token_quantity() {
193        let value = SearchValue::new(SearchPrefix::Eq, "http://loinc.org|8480-6$lt60");
194
195        let components = vec![
196            CompositeComponentDef {
197                param_type: SearchParamType::Token,
198                column_prefix: "code".to_string(),
199            },
200            CompositeComponentDef {
201                param_type: SearchParamType::Quantity,
202                column_prefix: "value".to_string(),
203            },
204        ];
205
206        let frag = CompositeHandler::build_sql(&value, &components, 0);
207
208        assert!(frag.sql.contains("value_token_system"));
209        assert!(frag.sql.contains("value_quantity_value"));
210        assert!(frag.sql.contains("AND"));
211    }
212
213    #[test]
214    fn test_composite_mismatched_parts() {
215        let value = SearchValue::new(SearchPrefix::Eq, "value1");
216
217        let components = vec![
218            CompositeComponentDef {
219                param_type: SearchParamType::Token,
220                column_prefix: "code".to_string(),
221            },
222            CompositeComponentDef {
223                param_type: SearchParamType::Quantity,
224                column_prefix: "value".to_string(),
225            },
226        ];
227
228        let frag = CompositeHandler::build_sql(&value, &components, 0);
229
230        // Should fail due to mismatch
231        assert!(frag.sql.contains("1 = 0"));
232    }
233
234    #[test]
235    fn test_composite_token_date() {
236        let value = SearchValue::new(SearchPrefix::Eq, "active$ge2024-01-01");
237
238        let components = vec![
239            CompositeComponentDef {
240                param_type: SearchParamType::Token,
241                column_prefix: "status".to_string(),
242            },
243            CompositeComponentDef {
244                param_type: SearchParamType::Date,
245                column_prefix: "date".to_string(),
246            },
247        ];
248
249        let frag = CompositeHandler::build_sql(&value, &components, 0);
250
251        assert!(frag.sql.contains("value_token_code"));
252        assert!(frag.sql.contains("value_date"));
253    }
254}