Skip to main content

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

1//! Token parameter SQL handler.
2
3use crate::types::{SearchModifier, SearchValue};
4
5use super::super::query_builder::{SqlFragment, SqlParam};
6
7/// Handles token parameter SQL generation.
8pub struct TokenHandler;
9
10impl TokenHandler {
11    /// Builds SQL for a token parameter value.
12    ///
13    /// Token values can be:
14    /// - `code` - matches any system
15    /// - `system|code` - matches specific system and code
16    /// - `|code` - matches code with no system (empty or null)
17    /// - `system|` - matches any code in system
18    ///
19    /// With `:of-type` modifier (for identifiers):
20    /// - `type-system|type-code|identifier-value` - matches identifier by type and value
21    pub fn build_sql(
22        value: &SearchValue,
23        modifier: Option<&SearchModifier>,
24        param_offset: usize,
25    ) -> SqlFragment {
26        let param_num = param_offset + 1;
27
28        // Handle :not modifier
29        if matches!(modifier, Some(SearchModifier::Not)) {
30            let inner = Self::build_sql(value, None, param_offset);
31            return SqlFragment::with_params(format!("NOT ({})", inner.sql), inner.params);
32        }
33
34        // Handle :text modifier - search on display text (Coding.display, CodeableConcept.text)
35        if matches!(modifier, Some(SearchModifier::Text)) {
36            // Search on the display text column for human-readable text matching
37            return SqlFragment::with_params(
38                format!(
39                    "value_token_display COLLATE NOCASE LIKE '%' || ?{} || '%'",
40                    param_num
41                ),
42                vec![SqlParam::string(value.value.to_lowercase())],
43            );
44        }
45
46        // Handle :text-advanced modifier - FTS5-based advanced text search
47        // Supports boolean operators (AND, OR, NOT), phrase matching, prefix search, and NEAR
48        if matches!(modifier, Some(SearchModifier::TextAdvanced)) {
49            return Self::build_text_advanced_sql(&value.value, param_offset);
50        }
51
52        // Handle :code-only modifier
53        if matches!(modifier, Some(SearchModifier::CodeOnly)) {
54            return SqlFragment::with_params(
55                format!("value_token_code = ?{}", param_num),
56                vec![SqlParam::string(&value.value)],
57            );
58        }
59
60        // Handle :of-type modifier (for identifier searches)
61        if matches!(modifier, Some(SearchModifier::OfType)) {
62            return Self::build_of_type_sql(&value.value, param_offset);
63        }
64
65        // Parse the token value
66        let token_value = &value.value;
67
68        if let Some(pipe_pos) = token_value.find('|') {
69            let system = &token_value[..pipe_pos];
70            let code = &token_value[pipe_pos + 1..];
71
72            if system.is_empty() {
73                // |code - match code with no system
74                SqlFragment::with_params(
75                    format!(
76                        "(value_token_system IS NULL OR value_token_system = '') AND value_token_code = ?{}",
77                        param_num
78                    ),
79                    vec![SqlParam::string(code)],
80                )
81            } else if code.is_empty() {
82                // system| - match any code in system
83                SqlFragment::with_params(
84                    format!("value_token_system = ?{}", param_num),
85                    vec![SqlParam::string(system)],
86                )
87            } else {
88                // system|code - exact match
89                SqlFragment::with_params(
90                    format!(
91                        "value_token_system = ?{} AND value_token_code = ?{}",
92                        param_num,
93                        param_num + 1
94                    ),
95                    vec![SqlParam::string(system), SqlParam::string(code)],
96                )
97            }
98        } else {
99            // code only - match any system
100            SqlFragment::with_params(
101                format!("value_token_code = ?{}", param_num),
102                vec![SqlParam::string(token_value)],
103            )
104        }
105    }
106
107    /// Builds SQL for the `:text-advanced` modifier using FTS5.
108    ///
109    /// The `:text-advanced` modifier (FHIR v6.0.0) provides advanced full-text
110    /// search capabilities including:
111    /// - Porter stemming (e.g., "running" matches "run")
112    /// - Boolean operators (AND, OR, NOT)
113    /// - Phrase matching ("heart attack")
114    /// - Prefix matching (cardio*)
115    /// - Proximity search (NEAR operator)
116    ///
117    /// This searches on the token display text (Coding.display, CodeableConcept.text)
118    /// that has been indexed in the FTS5 virtual table.
119    fn build_text_advanced_sql(query: &str, param_offset: usize) -> SqlFragment {
120        use super::super::fts::Fts5Search;
121
122        // Use FTS5 for advanced matching on token display text
123        // The search_index_fts now includes value_token_display
124        Fts5Search::build_advanced_query(query, param_offset + 1)
125    }
126
127    /// Builds SQL for the `:of-type` modifier used with identifier parameters.
128    ///
129    /// The `:of-type` modifier allows searching by both the type and value of an identifier.
130    /// Format: `type-system|type-code|identifier-value`
131    ///
132    /// For example:
133    /// - `Patient?identifier:of-type=http://terminology.hl7.org/CodeSystem/v2-0203|MR|12345`
134    ///   matches patients with a Medical Record Number identifier with value "12345".
135    ///
136    /// This implementation uses the dedicated type columns:
137    /// - `value_identifier_type_system` - stores identifier.type.coding[0].system
138    /// - `value_identifier_type_code` - stores identifier.type.coding[0].code
139    fn build_of_type_sql(value: &str, param_offset: usize) -> SqlFragment {
140        let mut param_num = param_offset + 1;
141
142        // Parse the three-part format: type-system|type-code|identifier-value
143        let parts: Vec<&str> = value.splitn(3, '|').collect();
144
145        match parts.len() {
146            3 => {
147                let type_system = parts[0];
148                let type_code = parts[1];
149                let identifier_value = parts[2];
150
151                let mut conditions = Vec::new();
152                let mut params = Vec::new();
153
154                // Always match on identifier value (required)
155                if !identifier_value.is_empty() {
156                    conditions.push(format!("value_token_code = ?{}", param_num));
157                    params.push(SqlParam::string(identifier_value));
158                    param_num += 1;
159                }
160
161                // Match on type system if provided
162                if !type_system.is_empty() {
163                    conditions.push(format!("value_identifier_type_system = ?{}", param_num));
164                    params.push(SqlParam::string(type_system));
165                    param_num += 1;
166                }
167
168                // Match on type code if provided
169                if !type_code.is_empty() {
170                    conditions.push(format!("value_identifier_type_code = ?{}", param_num));
171                    params.push(SqlParam::string(type_code));
172                }
173
174                if conditions.is_empty() {
175                    // No valid conditions
176                    SqlFragment::new("1 = 0")
177                } else {
178                    SqlFragment::with_params(conditions.join(" AND "), params)
179                }
180            }
181            2 => {
182                // type-code|value format (no type-system)
183                let type_code = parts[0];
184                let identifier_value = parts[1];
185
186                let mut conditions = Vec::new();
187                let mut params = Vec::new();
188
189                if !identifier_value.is_empty() {
190                    conditions.push(format!("value_token_code = ?{}", param_num));
191                    params.push(SqlParam::string(identifier_value));
192                    param_num += 1;
193                }
194
195                if !type_code.is_empty() {
196                    conditions.push(format!("value_identifier_type_code = ?{}", param_num));
197                    params.push(SqlParam::string(type_code));
198                }
199
200                if conditions.is_empty() {
201                    SqlFragment::new("1 = 0")
202                } else {
203                    SqlFragment::with_params(conditions.join(" AND "), params)
204                }
205            }
206            1 => {
207                // Just value (no type info)
208                SqlFragment::with_params(
209                    format!("value_token_code = ?{}", param_num),
210                    vec![SqlParam::string(value)],
211                )
212            }
213            _ => {
214                // Empty or invalid format - return empty match
215                SqlFragment::new("1 = 0")
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::types::SearchPrefix;
225
226    #[test]
227    fn test_token_code_only() {
228        let value = SearchValue::new(SearchPrefix::Eq, "12345");
229        let frag = TokenHandler::build_sql(&value, None, 0);
230
231        assert!(frag.sql.contains("value_token_code = ?1"));
232        assert_eq!(frag.params.len(), 1);
233    }
234
235    #[test]
236    fn test_token_system_and_code() {
237        let value = SearchValue::new(SearchPrefix::Eq, "http://loinc.org|12345-6");
238        let frag = TokenHandler::build_sql(&value, None, 0);
239
240        assert!(frag.sql.contains("value_token_system = ?1"));
241        assert!(frag.sql.contains("value_token_code = ?2"));
242        assert_eq!(frag.params.len(), 2);
243    }
244
245    #[test]
246    fn test_token_no_system() {
247        let value = SearchValue::new(SearchPrefix::Eq, "|12345");
248        let frag = TokenHandler::build_sql(&value, None, 0);
249
250        assert!(frag.sql.contains("IS NULL OR"));
251        assert!(frag.sql.contains("value_token_code = ?1"));
252    }
253
254    #[test]
255    fn test_token_system_only() {
256        let value = SearchValue::new(SearchPrefix::Eq, "http://loinc.org|");
257        let frag = TokenHandler::build_sql(&value, None, 0);
258
259        assert!(frag.sql.contains("value_token_system = ?1"));
260        assert!(!frag.sql.contains("value_token_code"));
261    }
262
263    #[test]
264    fn test_token_not_modifier() {
265        let value = SearchValue::new(SearchPrefix::Eq, "12345");
266        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::Not), 0);
267
268        assert!(frag.sql.starts_with("NOT ("));
269    }
270
271    #[test]
272    fn test_of_type_full_format() {
273        // Full format: type-system|type-code|identifier-value
274        let value = SearchValue::new(
275            SearchPrefix::Eq,
276            "http://terminology.hl7.org/CodeSystem/v2-0203|MR|12345",
277        );
278        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::OfType), 0);
279
280        // Should match identifier value, type system, and type code
281        assert!(frag.sql.contains("value_token_code = ?1"));
282        assert!(frag.sql.contains("value_identifier_type_system = ?2"));
283        assert!(frag.sql.contains("value_identifier_type_code = ?3"));
284        assert_eq!(frag.params.len(), 3);
285    }
286
287    #[test]
288    fn test_of_type_no_system() {
289        // Format without type-system: |type-code|value
290        let value = SearchValue::new(SearchPrefix::Eq, "|MR|12345");
291        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::OfType), 0);
292
293        // Should match identifier value and type code (no type system)
294        assert!(frag.sql.contains("value_token_code = ?1"));
295        assert!(frag.sql.contains("value_identifier_type_code = ?2"));
296        assert_eq!(frag.params.len(), 2);
297    }
298
299    #[test]
300    fn test_of_type_value_only() {
301        // Format with just type-code|value
302        let value = SearchValue::new(SearchPrefix::Eq, "MR|12345");
303        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::OfType), 0);
304
305        // Should match identifier value and type code
306        assert!(frag.sql.contains("value_token_code = ?1"));
307        assert!(frag.sql.contains("value_identifier_type_code = ?2"));
308        assert_eq!(frag.params.len(), 2);
309        // Verify parameters
310        if let SqlParam::String(s) = &frag.params[0] {
311            assert_eq!(s, "12345");
312        } else {
313            panic!("Expected string parameter for identifier value");
314        }
315        if let SqlParam::String(s) = &frag.params[1] {
316            assert_eq!(s, "MR");
317        } else {
318            panic!("Expected string parameter for type code");
319        }
320    }
321
322    // ============================================================================
323    // :text-advanced Modifier Tests
324    // ============================================================================
325
326    #[test]
327    fn test_text_advanced_simple() {
328        let value = SearchValue::new(SearchPrefix::Eq, "headache");
329        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::TextAdvanced), 0);
330
331        // Should use FTS5 MATCH
332        assert!(frag.sql.contains("search_index_fts"));
333        assert!(frag.sql.contains("MATCH"));
334        assert_eq!(frag.params.len(), 1);
335    }
336
337    #[test]
338    fn test_text_advanced_boolean_or() {
339        let value = SearchValue::new(SearchPrefix::Eq, "headache OR migraine");
340        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::TextAdvanced), 0);
341
342        assert!(frag.sql.contains("MATCH"));
343        // The query param should contain OR
344        if let SqlParam::String(s) = &frag.params[0] {
345            assert!(s.contains("OR"), "Query should contain OR: {}", s);
346        }
347    }
348
349    #[test]
350    fn test_text_advanced_phrase() {
351        let value = SearchValue::new(SearchPrefix::Eq, "\"heart failure\"");
352        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::TextAdvanced), 0);
353
354        assert!(frag.sql.contains("MATCH"));
355        // The query param should be a quoted phrase
356        if let SqlParam::String(s) = &frag.params[0] {
357            assert!(
358                s.contains("\"heart failure\""),
359                "Query should contain phrase: {}",
360                s
361            );
362        }
363    }
364
365    #[test]
366    fn test_text_advanced_prefix() {
367        let value = SearchValue::new(SearchPrefix::Eq, "cardio*");
368        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::TextAdvanced), 0);
369
370        assert!(frag.sql.contains("MATCH"));
371        // The query param should contain prefix wildcard
372        if let SqlParam::String(s) = &frag.params[0] {
373            assert!(s.contains("cardio*"), "Query should contain prefix: {}", s);
374        }
375    }
376
377    #[test]
378    fn test_text_advanced_not() {
379        let value = SearchValue::new(SearchPrefix::Eq, "-surgery");
380        let frag = TokenHandler::build_sql(&value, Some(&SearchModifier::TextAdvanced), 0);
381
382        assert!(frag.sql.contains("MATCH"));
383        // The query param should contain NOT
384        if let SqlParam::String(s) = &frag.params[0] {
385            assert!(s.contains("NOT"), "Query should contain NOT: {}", s);
386        }
387    }
388}