1use serde::{Deserialize, Serialize};
4use uls_core::codes::{LicenseStatus, OperatorClass, RadioService};
5
6fn convert_enum_value(field_name: &str, value: &str) -> Option<String> {
11 match field_name {
12 "status" | "license_status" => {
13 value
15 .parse::<LicenseStatus>()
16 .ok()
17 .map(|s| s.to_u8().to_string())
18 }
19 "class" | "operator_class" => {
20 value
22 .parse::<OperatorClass>()
23 .ok()
24 .map(|c| c.to_u8().to_string())
25 }
26 "service" | "radio_service" | "radio_service_code" => {
27 value
29 .parse::<RadioService>()
30 .ok()
31 .map(|s| s.to_u8().to_string())
32 }
33 _ => None, }
35}
36
37#[derive(Debug, Clone, PartialEq)]
39enum MatchPattern {
40 Exact(String),
42 Like(String),
44}
45
46impl MatchPattern {
47 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
69fn 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
79fn 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct SearchFilter {
99 #[serde(default)]
102 pub filters: Vec<crate::fields::FilterExpr>,
103 pub sort_field: Option<String>,
105 #[serde(default)]
107 pub sort_desc: bool,
108
109 pub callsign: Option<String>,
112 pub name: Option<String>,
114 pub city: Option<String>,
116 pub state: Option<String>,
118 pub zip_code: Option<String>,
120 pub radio_service: Option<Vec<String>>,
122 pub status: Option<char>,
124 pub operator_class: Option<char>,
126 pub active_only: bool,
128 pub frn: Option<String>,
130 pub limit: Option<usize>,
132 pub offset: Option<usize>,
134 pub sort: SortOrder,
136 pub granted_after: Option<String>,
138 pub granted_before: Option<String>,
140 pub expires_before: Option<String>,
142}
143
144impl SearchFilter {
145 pub fn new() -> Self {
147 Self::default()
148 }
149
150 pub fn callsign(callsign: impl Into<String>) -> Self {
152 Self {
153 callsign: Some(callsign.into()),
154 ..Default::default()
155 }
156 }
157
158 pub fn name(name: impl Into<String>) -> Self {
160 Self {
161 name: Some(name.into()),
162 ..Default::default()
163 }
164 }
165
166 pub fn location(city: Option<String>, state: Option<String>) -> Self {
168 Self {
169 city,
170 state,
171 ..Default::default()
172 }
173 }
174
175 pub fn with_limit(mut self, limit: usize) -> Self {
177 self.limit = Some(limit);
178 self
179 }
180
181 pub fn with_offset(mut self, offset: usize) -> Self {
183 self.offset = Some(offset);
184 self
185 }
186
187 pub fn active_only(mut self) -> Self {
189 self.active_only = true;
190 self.status = Some('A');
191 self
192 }
193
194 pub fn with_state(mut self, state: impl Into<String>) -> Self {
196 self.state = Some(state.into());
197 self
198 }
199
200 pub fn with_operator_class(mut self, class: char) -> Self {
202 self.operator_class = Some(class);
203 self
204 }
205
206 pub fn with_sort(mut self, sort: SortOrder) -> Self {
208 self.sort = sort;
209 self
210 }
211
212 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 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 pub fn to_where_clause(&self) -> (String, Vec<String>) {
235 let mut conditions = Vec::new();
236 let mut params = Vec::new();
237
238 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 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 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 if let Some(ref state) = self.state {
264 conditions.push("e.state = ?".to_string());
265 params.push(state.clone());
266 }
267
268 if let Some(ref zip) = self.zip_code {
270 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 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 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 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 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 let registry = crate::fields::FieldRegistry::new();
343 for expr in &self.filters {
344 if let Some(field_def) = registry.get(&expr.field) {
345 let op = if expr.value.contains('*') || expr.value.contains('?') {
347 crate::fields::FilterOp::Like
348 } else {
349 expr.op
350 };
351
352 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 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 pub fn order_clause(&self) -> String {
380 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
413pub enum SortOrder {
414 #[default]
416 CallSign,
417 CallSignDesc,
419 Name,
421 State,
423 GrantDate,
425 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); }
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 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 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 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 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 #[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 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 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 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 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 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 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 let filter = SearchFilter::new().with_filter("city=NEW*");
762 let (clause, params) = filter.to_where_clause();
763
764 assert!(clause.contains("LIKE"));
766 assert!(params.contains(&"NEW%".to_string()));
767 }
768
769 #[test]
770 fn test_generic_filter_unknown_field() {
771 let filter = SearchFilter::new().with_filter("unknown_field=value");
773 let (clause, _params) = filter.to_where_clause();
774
775 assert_eq!(clause, "1=1");
777 }
778
779 #[test]
780 fn test_zip_with_explicit_wildcard() {
781 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 assert!(params.contains(&"061%".to_string()));
789 }
790
791 #[test]
792 fn test_empty_radio_service_list() {
793 let mut filter = SearchFilter::new();
795 filter.radio_service = Some(vec![]);
796 let (clause, params) = filter.to_where_clause();
797
798 assert_eq!(clause, "1=1");
800 assert!(params.is_empty());
801 }
802
803 #[test]
804 fn test_generic_filter_enum_value_conversion() {
805 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 assert!(params.contains(&"0".to_string()));
812 }
813
814 #[test]
815 fn test_generic_filter_unknown_enum_fallback() {
816 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 assert!(params.contains(&"UNKNOWN".to_string()));
823 }
824}