Skip to main content

uls_query/
filter.rs

1//! Search filters for license queries.
2
3use serde::{Deserialize, Serialize};
4use uls_core::codes::{LicenseStatus, OperatorClass, RadioService};
5
6/// Convert enum field values from user-friendly strings to database integer codes.
7///
8/// For fields like `status`, `class`, and `service`, the database stores integer
9/// codes, not the string representations. This function handles the conversion.
10fn convert_enum_value(field_name: &str, value: &str) -> Option<String> {
11    match field_name {
12        "status" | "license_status" => {
13            // Try parsing as LicenseStatus char (A, E, C, T, X)
14            value
15                .parse::<LicenseStatus>()
16                .ok()
17                .map(|s| s.to_u8().to_string())
18        }
19        "class" | "operator_class" => {
20            // Try parsing as OperatorClass char (A, E, G, N, P, T)
21            value
22                .parse::<OperatorClass>()
23                .ok()
24                .map(|c| c.to_u8().to_string())
25        }
26        "service" | "radio_service" | "radio_service_code" => {
27            // Try parsing as RadioService code (HA, HV, etc)
28            value
29                .parse::<RadioService>()
30                .ok()
31                .map(|s| s.to_u8().to_string())
32        }
33        _ => None, // Not an enum field, no conversion needed
34    }
35}
36
37/// Result of analyzing a search pattern for wildcards.
38#[derive(Debug, Clone, PartialEq)]
39enum MatchPattern {
40    /// Exact match (no wildcards)
41    Exact(String),
42    /// Pattern match using SQL LIKE
43    Like(String),
44}
45
46impl MatchPattern {
47    /// Analyze a search term and determine the matching strategy.
48    ///
49    /// Wildcards:
50    /// - `*` matches any sequence of characters
51    /// - `?` matches exactly one character
52    ///
53    /// Examples:
54    /// - `SMITH` → Exact match
55    /// - `SMITH*` → Prefix match (`SMITH%`)
56    /// - `*SMITH` → Suffix match (`%SMITH`)
57    /// - `*SMITH*` → Contains match (`%SMITH%`)
58    /// - `SM?TH` → Single-char wildcard (`SM_TH`)
59    fn from_search_term(term: &str) -> Self {
60        if term.contains('*') || term.contains('?') {
61            let pattern = term.replace('*', "%").replace('?', "_");
62            MatchPattern::Like(pattern)
63        } else {
64            MatchPattern::Exact(term.to_string())
65        }
66    }
67}
68
69/// Generate a SQL condition and parameter for a text field match.
70///
71/// Returns (condition_string, parameters).
72fn text_match_condition(column: &str, value: &str) -> (String, Vec<String>) {
73    match MatchPattern::from_search_term(value) {
74        MatchPattern::Exact(v) => (format!("{} = ?", column), vec![v]),
75        MatchPattern::Like(pattern) => (format!("{} LIKE ?", column), vec![pattern]),
76    }
77}
78
79/// Generate SQL condition for matching across multiple columns (OR).
80///
81/// Useful for name searches that span entity_name, first_name, last_name.
82fn multi_column_match_condition(columns: &[&str], value: &str) -> (String, Vec<String>) {
83    let pattern = MatchPattern::from_search_term(value);
84
85    let (conditions, params): (Vec<_>, Vec<_>) = columns
86        .iter()
87        .map(|col| match &pattern {
88            MatchPattern::Exact(v) => (format!("{} = ?", col), v.clone()),
89            MatchPattern::Like(p) => (format!("{} LIKE ?", col), p.clone()),
90        })
91        .unzip();
92
93    (format!("({})", conditions.join(" OR ")), params)
94}
95
96/// Filter criteria for license searches.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct SearchFilter {
99    // ===== Generic filter system =====
100    /// Generic filter expressions (e.g., "grant_date>2025-01-01", "state=TX").
101    #[serde(default)]
102    pub filters: Vec<crate::fields::FilterExpr>,
103    /// Sort by field name (prefix with - for descending).
104    pub sort_field: Option<String>,
105    /// Sort direction (true = descending).
106    #[serde(default)]
107    pub sort_desc: bool,
108
109    // ===== Legacy convenience fields (still supported) =====
110    /// Filter by callsign pattern (supports wildcards).
111    pub callsign: Option<String>,
112    /// Filter by name (partial match).
113    pub name: Option<String>,
114    /// Filter by city.
115    pub city: Option<String>,
116    /// Filter by state (2-letter code).
117    pub state: Option<String>,
118    /// Filter by ZIP code.
119    pub zip_code: Option<String>,
120    /// Filter by radio service code(s).
121    pub radio_service: Option<Vec<String>>,
122    /// Filter by license status (A=Active, E=Expired, etc.).
123    pub status: Option<char>,
124    /// Filter by operator class (for amateur).
125    pub operator_class: Option<char>,
126    /// Only include active licenses.
127    pub active_only: bool,
128    /// FRN filter.
129    pub frn: Option<String>,
130    /// Maximum results to return.
131    pub limit: Option<usize>,
132    /// Number of results to skip (for pagination).
133    pub offset: Option<usize>,
134    /// Sort order (legacy enum, use sort_field for generic).
135    pub sort: SortOrder,
136    /// Filter by grant date (licenses granted on or after this date).
137    pub granted_after: Option<String>,
138    /// Filter by grant date (licenses granted on or before this date).
139    pub granted_before: Option<String>,
140    /// Filter by expiration date (licenses expiring on or before this date).
141    pub expires_before: Option<String>,
142}
143
144impl SearchFilter {
145    /// Create an empty filter.
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Create a filter for callsign lookup.
151    pub fn callsign(callsign: impl Into<String>) -> Self {
152        Self {
153            callsign: Some(callsign.into()),
154            ..Default::default()
155        }
156    }
157
158    /// Create a filter for name search.
159    pub fn name(name: impl Into<String>) -> Self {
160        Self {
161            name: Some(name.into()),
162            ..Default::default()
163        }
164    }
165
166    /// Create a filter for location search.
167    pub fn location(city: Option<String>, state: Option<String>) -> Self {
168        Self {
169            city,
170            state,
171            ..Default::default()
172        }
173    }
174
175    /// Set the maximum results.
176    pub fn with_limit(mut self, limit: usize) -> Self {
177        self.limit = Some(limit);
178        self
179    }
180
181    /// Set pagination offset.
182    pub fn with_offset(mut self, offset: usize) -> Self {
183        self.offset = Some(offset);
184        self
185    }
186
187    /// Only include active licenses.
188    pub fn active_only(mut self) -> Self {
189        self.active_only = true;
190        self.status = Some('A');
191        self
192    }
193
194    /// Filter by state.
195    pub fn with_state(mut self, state: impl Into<String>) -> Self {
196        self.state = Some(state.into());
197        self
198    }
199
200    /// Filter by operator class.
201    pub fn with_operator_class(mut self, class: char) -> Self {
202        self.operator_class = Some(class);
203        self
204    }
205
206    /// Set sort order.
207    pub fn with_sort(mut self, sort: SortOrder) -> Self {
208        self.sort = sort;
209        self
210    }
211
212    /// Add a generic filter expression (e.g., "grant_date>2025-01-01").
213    pub fn with_filter(mut self, expr: impl AsRef<str>) -> Self {
214        if let Some(filter) = crate::fields::FilterExpr::parse(expr.as_ref()) {
215            self.filters.push(filter);
216        }
217        self
218    }
219
220    /// Set sort field by name (prefix with - for descending).
221    pub fn with_sort_field(mut self, field: impl Into<String>) -> Self {
222        let field_str = field.into();
223        if let Some(rest) = field_str.strip_prefix('-') {
224            self.sort_field = Some(rest.to_string());
225            self.sort_desc = true;
226        } else {
227            self.sort_field = Some(field_str);
228            self.sort_desc = false;
229        }
230        self
231    }
232
233    /// Build the SQL WHERE clause for this filter.
234    pub fn to_where_clause(&self) -> (String, Vec<String>) {
235        let mut conditions = Vec::new();
236        let mut params = Vec::new();
237
238        // Callsign - exact or wildcard match
239        if let Some(ref callsign) = self.callsign {
240            let (cond, p) = text_match_condition("l.call_sign", callsign);
241            conditions.push(cond);
242            params.extend(p);
243        }
244
245        // Name - search across entity_name, first_name, last_name
246        if let Some(ref name) = self.name {
247            let (cond, p) = multi_column_match_condition(
248                &["e.entity_name", "e.first_name", "e.last_name"],
249                name,
250            );
251            conditions.push(cond);
252            params.extend(p);
253        }
254
255        // City - exact or wildcard match
256        if let Some(ref city) = self.city {
257            let (cond, p) = text_match_condition("e.city", city);
258            conditions.push(cond);
259            params.extend(p);
260        }
261
262        // State - always exact match (2-letter code)
263        if let Some(ref state) = self.state {
264            conditions.push("e.state = ?".to_string());
265            params.push(state.clone());
266        }
267
268        // ZIP - prefix match by default (allows 5-digit or 9-digit)
269        if let Some(ref zip) = self.zip_code {
270            // If no wildcards, treat as prefix search
271            let value = if zip.contains('*') || zip.contains('?') {
272                zip.clone()
273            } else {
274                format!("{}*", zip)
275            };
276            let (cond, p) = text_match_condition("e.zip_code", &value);
277            conditions.push(cond);
278            params.extend(p);
279        }
280
281        // Convert status char to integer code for comparison
282        if let Some(status) = self.status {
283            if let Ok(status_enum) = status.to_string().parse::<LicenseStatus>() {
284                conditions.push("l.license_status = ?".to_string());
285                params.push(status_enum.to_u8().to_string());
286            }
287        } else if self.active_only {
288            let active_code = LicenseStatus::Active.to_u8();
289            conditions.push(format!("l.license_status = {}", active_code));
290        }
291
292        // Convert operator_class char to integer code for comparison
293        if let Some(class) = self.operator_class {
294            if let Ok(class_enum) = class.to_string().parse::<OperatorClass>() {
295                conditions.push("a.operator_class = ?".to_string());
296                params.push(class_enum.to_u8().to_string());
297            }
298        }
299
300        if let Some(ref frn) = self.frn {
301            conditions.push("e.frn = ?".to_string());
302            params.push(frn.clone());
303        }
304
305        // Convert radio_service strings to integer codes for comparison
306        if let Some(ref services) = self.radio_service {
307            let codes: Vec<String> = services
308                .iter()
309                .filter_map(|s| {
310                    s.parse::<RadioService>()
311                        .ok()
312                        .map(|r| r.to_u8().to_string())
313                })
314                .collect();
315            if !codes.is_empty() {
316                let placeholders: Vec<String> = codes.iter().map(|_| "?".to_string()).collect();
317                conditions.push(format!(
318                    "l.radio_service_code IN ({})",
319                    placeholders.join(", ")
320                ));
321                params.extend(codes);
322            }
323        }
324
325        // Date range filters
326        if let Some(ref date) = self.granted_after {
327            conditions.push("l.grant_date >= ?".to_string());
328            params.push(date.clone());
329        }
330
331        if let Some(ref date) = self.granted_before {
332            conditions.push("l.grant_date <= ?".to_string());
333            params.push(date.clone());
334        }
335
336        if let Some(ref date) = self.expires_before {
337            conditions.push("l.expired_date <= ?".to_string());
338            params.push(date.clone());
339        }
340
341        // Process generic filter expressions
342        let registry = crate::fields::FieldRegistry::new();
343        for expr in &self.filters {
344            if let Some(field_def) = registry.get(&expr.field) {
345                // Check wildcards for LIKE
346                let op = if expr.value.contains('*') || expr.value.contains('?') {
347                    crate::fields::FilterOp::Like
348                } else {
349                    expr.op
350                };
351
352                // Validate operator for field type
353                if op.valid_for(field_def.field_type) {
354                    if op == crate::fields::FilterOp::Like {
355                        let pattern = expr.value.replace('*', "%").replace('?', "_");
356                        conditions.push(format!("{} LIKE ?", field_def.column));
357                        params.push(pattern);
358                    } else {
359                        // Convert enum values (status, class, service) to integer codes
360                        let param_value = convert_enum_value(&expr.field, &expr.value)
361                            .unwrap_or_else(|| expr.value.clone());
362                        conditions.push(format!("{} {} ?", field_def.column, op.sql()));
363                        params.push(param_value);
364                    }
365                }
366            }
367        }
368
369        let where_clause = if conditions.is_empty() {
370            "1=1".to_string()
371        } else {
372            conditions.join(" AND ")
373        };
374
375        (where_clause, params)
376    }
377
378    /// Get the ORDER BY clause.
379    pub fn order_clause(&self) -> String {
380        // If sort_field is set, use generic field-based sorting
381        if let Some(ref field_name) = self.sort_field {
382            let registry = crate::fields::FieldRegistry::new();
383            if let Some(field_def) = registry.get(field_name) {
384                let dir = if self.sort_desc { "DESC" } else { "ASC" };
385                return format!("ORDER BY {} {}", field_def.column, dir);
386            }
387        }
388
389        // Fall back to legacy SortOrder enum
390        match self.sort {
391            SortOrder::CallSign => "ORDER BY l.call_sign ASC".to_string(),
392            SortOrder::CallSignDesc => "ORDER BY l.call_sign DESC".to_string(),
393            SortOrder::Name => "ORDER BY e.entity_name ASC, e.last_name ASC".to_string(),
394            SortOrder::State => "ORDER BY e.state ASC, e.city ASC".to_string(),
395            SortOrder::GrantDate => "ORDER BY l.grant_date DESC".to_string(),
396            SortOrder::ExpirationDate => "ORDER BY l.expired_date ASC".to_string(),
397        }
398    }
399
400    /// Get the LIMIT clause.
401    pub fn limit_clause(&self) -> String {
402        match (self.limit, self.offset) {
403            (Some(limit), Some(offset)) => format!("LIMIT {} OFFSET {}", limit, offset),
404            (Some(limit), None) => format!("LIMIT {}", limit),
405            (None, Some(offset)) => format!("LIMIT -1 OFFSET {}", offset),
406            (None, None) => String::new(),
407        }
408    }
409}
410
411/// Sort order for search results.
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
413pub enum SortOrder {
414    /// Sort by callsign ascending (default).
415    #[default]
416    CallSign,
417    /// Sort by callsign descending.
418    CallSignDesc,
419    /// Sort by name.
420    Name,
421    /// Sort by state, then city.
422    State,
423    /// Sort by grant date (newest first).
424    GrantDate,
425    /// Sort by expiration date (soonest first).
426    ExpirationDate,
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_callsign_filter() {
435        let filter = SearchFilter::callsign("W1AW");
436        let (clause, params) = filter.to_where_clause();
437        assert!(clause.contains("call_sign"));
438        assert_eq!(params, vec!["W1AW"]);
439    }
440
441    #[test]
442    fn test_wildcard_filter() {
443        let filter = SearchFilter::callsign("W1*");
444        let (clause, params) = filter.to_where_clause();
445        assert!(clause.contains("LIKE"));
446        assert_eq!(params, vec!["W1%"]);
447    }
448
449    #[test]
450    fn test_name_filter() {
451        let filter = SearchFilter::name("SMITH");
452        let (clause, params) = filter.to_where_clause();
453        assert!(clause.contains("entity_name"));
454        assert_eq!(params.len(), 3); // entity_name, first_name, last_name
455    }
456
457    #[test]
458    fn test_combined_filter() {
459        let filter = SearchFilter::new()
460            .with_state("CT")
461            .with_operator_class('E')
462            .active_only();
463
464        let (clause, params) = filter.to_where_clause();
465        assert!(clause.contains("state"));
466        assert!(clause.contains("operator_class"));
467        assert!(clause.contains("license_status"));
468        assert!(params.contains(&"CT".to_string()));
469        // operator_class 'E' = OperatorClass::Extra = code 3
470        assert!(params.contains(&OperatorClass::Extra.to_u8().to_string()));
471    }
472
473    #[test]
474    fn test_limit_offset() {
475        let filter = SearchFilter::new().with_limit(50).with_offset(100);
476        assert_eq!(filter.limit_clause(), "LIMIT 50 OFFSET 100");
477    }
478
479    #[test]
480    fn test_limit_only() {
481        let filter = SearchFilter::new().with_limit(25);
482        assert_eq!(filter.limit_clause(), "LIMIT 25");
483    }
484
485    #[test]
486    fn test_offset_only() {
487        let filter = SearchFilter::new().with_offset(50);
488        assert_eq!(filter.limit_clause(), "LIMIT -1 OFFSET 50");
489    }
490
491    #[test]
492    fn test_location_filter() {
493        // Use wildcards for contains match
494        let filter =
495            SearchFilter::location(Some("*NEWINGTON*".to_string()), Some("CT".to_string()));
496        let (clause, params) = filter.to_where_clause();
497        assert!(clause.contains("city"));
498        assert!(clause.contains("state"));
499        assert!(params.contains(&"%NEWINGTON%".to_string()));
500        assert!(params.contains(&"CT".to_string()));
501    }
502
503    #[test]
504    fn test_frn_filter() {
505        let mut filter = SearchFilter::new();
506        filter.frn = Some("0001234567".to_string());
507        let (clause, params) = filter.to_where_clause();
508        assert!(clause.contains("frn"));
509        assert!(params.contains(&"0001234567".to_string()));
510    }
511
512    #[test]
513    fn test_zip_filter() {
514        let mut filter = SearchFilter::new();
515        filter.zip_code = Some("06111".to_string());
516        let (clause, params) = filter.to_where_clause();
517        assert!(clause.contains("zip_code"));
518        assert!(params.contains(&"06111%".to_string()));
519    }
520
521    #[test]
522    fn test_radio_service_filter() {
523        let mut filter = SearchFilter::new();
524        filter.radio_service = Some(vec!["HA".to_string(), "HV".to_string()]);
525        let (clause, params) = filter.to_where_clause();
526        assert!(clause.contains("radio_service_code IN"));
527        // HA = RadioService::HA, HV = RadioService::HV - check for integer codes
528        assert!(params.contains(&RadioService::HA.to_u8().to_string()));
529        assert!(params.contains(&RadioService::HV.to_u8().to_string()));
530    }
531
532    #[test]
533    fn test_sort_orders() {
534        let filter = SearchFilter::new().with_sort(SortOrder::CallSign);
535        assert!(filter.order_clause().contains("call_sign ASC"));
536
537        let filter = SearchFilter::new().with_sort(SortOrder::CallSignDesc);
538        assert!(filter.order_clause().contains("call_sign DESC"));
539
540        let filter = SearchFilter::new().with_sort(SortOrder::Name);
541        assert!(filter.order_clause().contains("entity_name"));
542
543        let filter = SearchFilter::new().with_sort(SortOrder::State);
544        assert!(filter.order_clause().contains("state"));
545
546        let filter = SearchFilter::new().with_sort(SortOrder::GrantDate);
547        assert!(filter.order_clause().contains("grant_date"));
548
549        let filter = SearchFilter::new().with_sort(SortOrder::ExpirationDate);
550        assert!(filter.order_clause().contains("expired_date"));
551    }
552
553    #[test]
554    fn test_empty_filter() {
555        let filter = SearchFilter::new();
556        let (clause, params) = filter.to_where_clause();
557        assert_eq!(clause, "1=1");
558        assert!(params.is_empty());
559    }
560
561    #[test]
562    fn test_single_char_wildcard() {
563        let filter = SearchFilter::callsign("W1A?");
564        let (clause, params) = filter.to_where_clause();
565        assert!(clause.contains("LIKE"));
566        assert_eq!(params, vec!["W1A_"]);
567    }
568
569    #[test]
570    fn test_match_pattern_exact() {
571        let pattern = MatchPattern::from_search_term("SMITH");
572        assert_eq!(pattern, MatchPattern::Exact("SMITH".to_string()));
573    }
574
575    #[test]
576    fn test_match_pattern_prefix() {
577        let pattern = MatchPattern::from_search_term("SMITH*");
578        assert_eq!(pattern, MatchPattern::Like("SMITH%".to_string()));
579    }
580
581    #[test]
582    fn test_match_pattern_suffix() {
583        let pattern = MatchPattern::from_search_term("*SMITH");
584        assert_eq!(pattern, MatchPattern::Like("%SMITH".to_string()));
585    }
586
587    #[test]
588    fn test_match_pattern_contains() {
589        let pattern = MatchPattern::from_search_term("*SMITH*");
590        assert_eq!(pattern, MatchPattern::Like("%SMITH%".to_string()));
591    }
592
593    #[test]
594    fn test_text_match_condition_exact() {
595        let (cond, params) = text_match_condition("name", "SMITH");
596        assert_eq!(cond, "name = ?");
597        assert_eq!(params, vec!["SMITH"]);
598    }
599
600    #[test]
601    fn test_text_match_condition_like() {
602        let (cond, params) = text_match_condition("name", "SMITH*");
603        assert_eq!(cond, "name LIKE ?");
604        assert_eq!(params, vec!["SMITH%"]);
605    }
606
607    #[test]
608    fn test_multi_column_match_exact() {
609        let (cond, params) = multi_column_match_condition(&["a", "b", "c"], "VALUE");
610        assert_eq!(cond, "(a = ? OR b = ? OR c = ?)");
611        assert_eq!(params, vec!["VALUE", "VALUE", "VALUE"]);
612    }
613
614    #[test]
615    fn test_multi_column_match_like() {
616        let (cond, params) = multi_column_match_condition(&["a", "b"], "*VALUE*");
617        assert_eq!(cond, "(a LIKE ? OR b LIKE ?)");
618        assert_eq!(params, vec!["%VALUE%", "%VALUE%"]);
619    }
620
621    #[test]
622    fn test_exact_city_match() {
623        // No wildcards = exact match
624        let mut filter = SearchFilter::new();
625        filter.city = Some("NEWINGTON".to_string());
626        let (clause, params) = filter.to_where_clause();
627        assert!(clause.contains("city = ?"));
628        assert_eq!(params, vec!["NEWINGTON"]);
629    }
630
631    // Case-insensitive tests - inputs are passed through unchanged,
632    // relying on COLLATE NOCASE in the database schema
633    #[test]
634    fn test_lowercase_name_filter() {
635        let filter = SearchFilter::name("smith");
636        let (clause, params) = filter.to_where_clause();
637        assert!(clause.contains("entity_name"));
638        // Should contain lowercase - DB handles case-insensitivity
639        assert!(params.iter().any(|p| p == "smith"));
640    }
641
642    #[test]
643    fn test_lowercase_city_filter() {
644        let mut filter = SearchFilter::new();
645        filter.city = Some("newington".to_string());
646        let (clause, params) = filter.to_where_clause();
647        assert!(clause.contains("city = ?"));
648        assert_eq!(params, vec!["newington"]);
649    }
650
651    #[test]
652    fn test_lowercase_callsign_filter() {
653        let filter = SearchFilter::callsign("w1aw");
654        let (clause, params) = filter.to_where_clause();
655        assert!(clause.contains("call_sign"));
656        assert_eq!(params, vec!["w1aw"]);
657    }
658
659    #[test]
660    fn test_mixed_case_wildcard_name() {
661        let filter = SearchFilter::name("*Smith*");
662        let (clause, params) = filter.to_where_clause();
663        assert!(clause.contains("LIKE"));
664        // Wildcards converted to SQL LIKE pattern
665        assert!(params.iter().any(|p| p == "%Smith%"));
666    }
667
668    #[test]
669    fn test_with_filter() {
670        let filter = SearchFilter::new().with_filter("grant_date>2025-01-01");
671        assert_eq!(filter.filters.len(), 1);
672        assert_eq!(filter.filters[0].field, "grant_date");
673        assert_eq!(filter.filters[0].op, crate::fields::FilterOp::Gt);
674        assert_eq!(filter.filters[0].value, "2025-01-01");
675    }
676
677    #[test]
678    fn test_with_filter_invalid_ignored() {
679        // Invalid filter expressions should be silently ignored
680        let filter = SearchFilter::new().with_filter("invalid");
681        assert_eq!(filter.filters.len(), 0);
682    }
683
684    #[test]
685    fn test_with_sort_field_descending() {
686        let filter = SearchFilter::new().with_sort_field("-call_sign");
687        assert_eq!(filter.sort_field, Some("call_sign".to_string()));
688        assert!(filter.sort_desc);
689
690        // Test that order_clause produces DESC
691        let clause = filter.order_clause();
692        assert!(clause.contains("DESC"), "Expected DESC in: {}", clause);
693    }
694
695    #[test]
696    fn test_with_sort_field_ascending() {
697        let filter = SearchFilter::new().with_sort_field("grant_date");
698        assert_eq!(filter.sort_field, Some("grant_date".to_string()));
699        assert!(!filter.sort_desc);
700
701        // Test that order_clause produces ASC
702        let clause = filter.order_clause();
703        assert!(clause.contains("ASC"), "Expected ASC in: {}", clause);
704    }
705
706    #[test]
707    fn test_granted_after_filter() {
708        let mut filter = SearchFilter::new();
709        filter.granted_after = Some("2025-01-01".to_string());
710        let (clause, params) = filter.to_where_clause();
711        assert!(clause.contains("grant_date >="));
712        assert!(params.contains(&"2025-01-01".to_string()));
713    }
714
715    #[test]
716    fn test_granted_before_filter() {
717        let mut filter = SearchFilter::new();
718        filter.granted_before = Some("2025-12-31".to_string());
719        let (clause, params) = filter.to_where_clause();
720        assert!(clause.contains("grant_date <="));
721        assert!(params.contains(&"2025-12-31".to_string()));
722    }
723
724    #[test]
725    fn test_expires_before_filter() {
726        let mut filter = SearchFilter::new();
727        filter.expires_before = Some("2026-01-01".to_string());
728        let (clause, params) = filter.to_where_clause();
729        assert!(clause.contains("expired_date <="));
730        assert!(params.contains(&"2026-01-01".to_string()));
731    }
732
733    #[test]
734    fn test_date_range_combined() {
735        let mut filter = SearchFilter::new();
736        filter.granted_after = Some("2025-01-01".to_string());
737        filter.granted_before = Some("2025-12-31".to_string());
738        filter.expires_before = Some("2030-01-01".to_string());
739        let (clause, params) = filter.to_where_clause();
740
741        assert!(clause.contains("grant_date >="));
742        assert!(clause.contains("grant_date <="));
743        assert!(clause.contains("expired_date <="));
744        assert_eq!(params.len(), 3);
745    }
746
747    #[test]
748    fn test_generic_filter_expression_date() {
749        let filter = SearchFilter::new().with_filter("grant_date>2025-01-01");
750        let (clause, params) = filter.to_where_clause();
751
752        // Should contain the date filter
753        assert!(clause.contains("grant_date"));
754        assert!(clause.contains(">"));
755        assert!(params.contains(&"2025-01-01".to_string()));
756    }
757
758    #[test]
759    fn test_generic_filter_expression_with_wildcard() {
760        // Wildcards in filter values should trigger LIKE
761        let filter = SearchFilter::new().with_filter("city=NEW*");
762        let (clause, params) = filter.to_where_clause();
763
764        // Should use LIKE due to wildcard
765        assert!(clause.contains("LIKE"));
766        assert!(params.contains(&"NEW%".to_string()));
767    }
768
769    #[test]
770    fn test_generic_filter_unknown_field() {
771        // Unknown fields should be silently ignored
772        let filter = SearchFilter::new().with_filter("unknown_field=value");
773        let (clause, _params) = filter.to_where_clause();
774
775        // Should not include unknown field, just default 1=1
776        assert_eq!(clause, "1=1");
777    }
778
779    #[test]
780    fn test_zip_with_explicit_wildcard() {
781        // ZIP with explicit wildcard should preserve it
782        let mut filter = SearchFilter::new();
783        filter.zip_code = Some("061*".to_string());
784        let (clause, params) = filter.to_where_clause();
785
786        assert!(clause.contains("LIKE"));
787        // Should use the explicit wildcard pattern, not add another
788        assert!(params.contains(&"061%".to_string()));
789    }
790
791    #[test]
792    fn test_empty_radio_service_list() {
793        // Empty radio service list should not add a condition
794        let mut filter = SearchFilter::new();
795        filter.radio_service = Some(vec![]);
796        let (clause, params) = filter.to_where_clause();
797
798        // Should be empty filter (1=1)
799        assert_eq!(clause, "1=1");
800        assert!(params.is_empty());
801    }
802
803    #[test]
804    fn test_generic_filter_enum_value_conversion() {
805        // status=A should be converted to integer code (1)
806        let filter = SearchFilter::new().with_filter("status=A");
807        let (clause, params) = filter.to_where_clause();
808
809        assert!(clause.contains("l.license_status"));
810        // LicenseStatus::Active is code 0
811        assert!(params.contains(&"0".to_string()));
812    }
813
814    #[test]
815    fn test_generic_filter_unknown_enum_fallback() {
816        // Unknown status value should fall back to original string
817        let filter = SearchFilter::new().with_filter("status=UNKNOWN");
818        let (clause, params) = filter.to_where_clause();
819
820        assert!(clause.contains("l.license_status"));
821        // Falls back to original string since parse failed
822        assert!(params.contains(&"UNKNOWN".to_string()));
823    }
824}