Skip to main content

uls_query/
fields.rs

1//! Field registry and generic filter expressions.
2//!
3//! Provides type-aware filtering and sorting for any registered field.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Field data types that determine allowed filter operations.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FieldType {
11    /// String field: supports =, LIKE (wildcards * ?)
12    String,
13    /// Date field (YYYY-MM-DD): supports =, <, >, <=, >=
14    Date,
15    /// Single-char enum (status, class): supports =
16    Char,
17}
18
19/// Comparison operators for filters.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum FilterOp {
22    /// Exact match (=)
23    Eq,
24    /// Not equal (!=)
25    Ne,
26    /// Less than (<)
27    Lt,
28    /// Less than or equal (<=)
29    Le,
30    /// Greater than (>)
31    Gt,
32    /// Greater than or equal (>=)
33    Ge,
34    /// Pattern match (LIKE with wildcards)
35    Like,
36}
37
38impl FilterOp {
39    /// Parse operator from string prefix.
40    pub fn parse(s: &str) -> (Self, &str) {
41        if let Some(rest) = s.strip_prefix(">=") {
42            (FilterOp::Ge, rest)
43        } else if let Some(rest) = s.strip_prefix("<=") {
44            (FilterOp::Le, rest)
45        } else if let Some(rest) = s.strip_prefix("!=") {
46            (FilterOp::Ne, rest)
47        } else if let Some(rest) = s.strip_prefix('>') {
48            (FilterOp::Gt, rest)
49        } else if let Some(rest) = s.strip_prefix('<') {
50            (FilterOp::Lt, rest)
51        } else if let Some(rest) = s.strip_prefix('=') {
52            (FilterOp::Eq, rest)
53        } else {
54            // No operator prefix = Eq
55            (FilterOp::Eq, s)
56        }
57    }
58
59    /// Check if this operator is valid for the given field type.
60    pub fn valid_for(&self, field_type: FieldType) -> bool {
61        match field_type {
62            FieldType::String => matches!(self, FilterOp::Eq | FilterOp::Ne | FilterOp::Like),
63            FieldType::Date => true, // All ops valid for dates
64            FieldType::Char => matches!(self, FilterOp::Eq | FilterOp::Ne),
65        }
66    }
67
68    /// Get SQL operator string.
69    pub fn sql(&self) -> &'static str {
70        match self {
71            FilterOp::Eq => "=",
72            FilterOp::Ne => "!=",
73            FilterOp::Lt => "<",
74            FilterOp::Le => "<=",
75            FilterOp::Gt => ">",
76            FilterOp::Ge => ">=",
77            FilterOp::Like => "LIKE",
78        }
79    }
80}
81
82/// A single filter expression: field op value.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FilterExpr {
85    pub field: String,
86    pub op: FilterOp,
87    pub value: String,
88}
89
90impl FilterExpr {
91    /// Parse a filter expression like "grant_date>2025-01-01" or "state=TX".
92    pub fn parse(s: &str) -> Option<Self> {
93        // Find the operator
94        let op_chars = ['>', '<', '=', '!'];
95        let op_pos = s.find(|c: char| op_chars.contains(&c))?;
96
97        let field = s[..op_pos].trim().to_lowercase();
98        let rest = &s[op_pos..];
99        let (op, value_str) = FilterOp::parse(rest);
100        let value = value_str.trim().to_string();
101
102        if field.is_empty() || value.is_empty() {
103            return None;
104        }
105
106        Some(FilterExpr { field, op, value })
107    }
108}
109
110/// Field definition with SQL column mapping.
111#[derive(Debug, Clone)]
112pub struct FieldDef {
113    /// User-facing field name (lowercase).
114    pub name: &'static str,
115    /// SQL column expression.
116    pub column: &'static str,
117    /// Field type for validation.
118    pub field_type: FieldType,
119    /// Aliases for this field.
120    pub aliases: &'static [&'static str],
121}
122
123/// Registry of all searchable/sortable fields.
124pub struct FieldRegistry {
125    fields: HashMap<&'static str, FieldDef>,
126}
127
128impl FieldRegistry {
129    /// Create the default field registry with all license fields.
130    pub fn new() -> Self {
131        let mut fields = HashMap::new();
132
133        let defs = [
134            FieldDef {
135                name: "call_sign",
136                column: "l.call_sign",
137                field_type: FieldType::String,
138                aliases: &["callsign", "call"],
139            },
140            FieldDef {
141                name: "name",
142                column: "e.entity_name",
143                field_type: FieldType::String,
144                aliases: &["entity_name", "licensee"],
145            },
146            FieldDef {
147                name: "first_name",
148                column: "e.first_name",
149                field_type: FieldType::String,
150                aliases: &["first"],
151            },
152            FieldDef {
153                name: "last_name",
154                column: "e.last_name",
155                field_type: FieldType::String,
156                aliases: &["last"],
157            },
158            FieldDef {
159                name: "city",
160                column: "e.city",
161                field_type: FieldType::String,
162                aliases: &[],
163            },
164            FieldDef {
165                name: "state",
166                column: "e.state",
167                field_type: FieldType::String,
168                aliases: &[],
169            },
170            FieldDef {
171                name: "zip_code",
172                column: "e.zip_code",
173                field_type: FieldType::String,
174                aliases: &["zip"],
175            },
176            FieldDef {
177                name: "frn",
178                column: "e.frn",
179                field_type: FieldType::String,
180                aliases: &[],
181            },
182            FieldDef {
183                name: "status",
184                column: "l.license_status",
185                field_type: FieldType::Char,
186                aliases: &["license_status"],
187            },
188            FieldDef {
189                name: "class",
190                column: "a.operator_class",
191                field_type: FieldType::Char,
192                aliases: &["operator_class"],
193            },
194            FieldDef {
195                name: "service",
196                column: "l.radio_service_code",
197                field_type: FieldType::String,
198                aliases: &["radio_service", "radio_service_code"],
199            },
200            FieldDef {
201                name: "grant_date",
202                column: "l.grant_date",
203                field_type: FieldType::Date,
204                aliases: &["granted"],
205            },
206            FieldDef {
207                name: "expired_date",
208                column: "l.expired_date",
209                field_type: FieldType::Date,
210                aliases: &["expires", "expiration"],
211            },
212            FieldDef {
213                name: "cancellation_date",
214                column: "l.cancellation_date",
215                field_type: FieldType::Date,
216                aliases: &["cancelled"],
217            },
218        ];
219
220        for def in defs {
221            // Register by name
222            fields.insert(def.name, def.clone());
223            // Register by aliases
224            for &alias in def.aliases {
225                fields.insert(alias, def.clone());
226            }
227        }
228
229        FieldRegistry { fields }
230    }
231
232    /// Look up a field by name or alias.
233    pub fn get(&self, name: &str) -> Option<&FieldDef> {
234        self.fields.get(name.to_lowercase().as_str())
235    }
236
237    /// Get all canonical field names (no aliases).
238    pub fn field_names(&self) -> Vec<&'static str> {
239        let mut names: Vec<_> = self.fields.values().map(|f| f.name).collect();
240        names.sort();
241        names.dedup();
242        names
243    }
244}
245
246impl Default for FieldRegistry {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_filter_op_parse() {
258        assert_eq!(FilterOp::parse(">=2025"), (FilterOp::Ge, "2025"));
259        assert_eq!(FilterOp::parse("<=2025"), (FilterOp::Le, "2025"));
260        assert_eq!(FilterOp::parse(">2025"), (FilterOp::Gt, "2025"));
261        assert_eq!(FilterOp::parse("<2025"), (FilterOp::Lt, "2025"));
262        assert_eq!(FilterOp::parse("=TX"), (FilterOp::Eq, "TX"));
263        assert_eq!(FilterOp::parse("TX"), (FilterOp::Eq, "TX"));
264    }
265
266    #[test]
267    fn test_filter_expr_parse() {
268        let expr = FilterExpr::parse("grant_date>2025-01-01").unwrap();
269        assert_eq!(expr.field, "grant_date");
270        assert_eq!(expr.op, FilterOp::Gt);
271        assert_eq!(expr.value, "2025-01-01");
272
273        let expr = FilterExpr::parse("state=TX").unwrap();
274        assert_eq!(expr.field, "state");
275        assert_eq!(expr.op, FilterOp::Eq);
276        assert_eq!(expr.value, "TX");
277    }
278
279    #[test]
280    fn test_field_registry() {
281        let reg = FieldRegistry::new();
282
283        // By name
284        assert!(reg.get("call_sign").is_some());
285        assert!(reg.get("grant_date").is_some());
286
287        // By alias
288        assert!(reg.get("callsign").is_some());
289        assert!(reg.get("granted").is_some());
290        assert!(reg.get("zip").is_some());
291
292        // Unknown
293        assert!(reg.get("unknown_field").is_none());
294    }
295
296    #[test]
297    fn test_op_validity() {
298        // String: only =, !=, LIKE
299        assert!(FilterOp::Eq.valid_for(FieldType::String));
300        assert!(FilterOp::Like.valid_for(FieldType::String));
301        assert!(!FilterOp::Gt.valid_for(FieldType::String));
302
303        // Date: all ops
304        assert!(FilterOp::Gt.valid_for(FieldType::Date));
305        assert!(FilterOp::Le.valid_for(FieldType::Date));
306
307        // Char: only =, !=
308        assert!(FilterOp::Eq.valid_for(FieldType::Char));
309        assert!(!FilterOp::Gt.valid_for(FieldType::Char));
310    }
311
312    #[test]
313    fn test_filter_op_parse_not_equal() {
314        // Test the != operator which wasn't covered
315        assert_eq!(FilterOp::parse("!=value"), (FilterOp::Ne, "value"));
316    }
317
318    #[test]
319    fn test_filter_op_sql() {
320        // Test all SQL operator conversions
321        assert_eq!(FilterOp::Eq.sql(), "=");
322        assert_eq!(FilterOp::Ne.sql(), "!=");
323        assert_eq!(FilterOp::Lt.sql(), "<");
324        assert_eq!(FilterOp::Le.sql(), "<=");
325        assert_eq!(FilterOp::Gt.sql(), ">");
326        assert_eq!(FilterOp::Ge.sql(), ">=");
327        assert_eq!(FilterOp::Like.sql(), "LIKE");
328    }
329
330    #[test]
331    fn test_field_registry_field_names() {
332        let reg = FieldRegistry::new();
333        let names = reg.field_names();
334
335        // Should contain canonical field names
336        assert!(names.contains(&"call_sign"));
337        assert!(names.contains(&"city"));
338        assert!(names.contains(&"state"));
339        assert!(names.contains(&"grant_date"));
340        assert!(names.contains(&"expired_date"));
341
342        // Should not contain aliases (deduped by canonical name)
343        // All returned values should be unique
344        let unique_count = names.len();
345        let mut sorted_names = names.clone();
346        sorted_names.sort();
347        sorted_names.dedup();
348        assert_eq!(unique_count, sorted_names.len());
349    }
350
351    #[test]
352    fn test_field_registry_default() {
353        // Test the Default implementation
354        let reg: FieldRegistry = FieldRegistry::default();
355
356        // Should work the same as new()
357        assert!(reg.get("call_sign").is_some());
358        assert!(reg.get("callsign").is_some()); // alias
359    }
360
361    #[test]
362    fn test_filter_expr_parse_invalid() {
363        // No operator found
364        assert!(FilterExpr::parse("nooperator").is_none());
365
366        // Empty field
367        assert!(FilterExpr::parse("=value").is_none());
368
369        // Empty value
370        assert!(FilterExpr::parse("field=").is_none());
371    }
372
373    #[test]
374    fn test_filter_expr_parse_not_equal() {
375        let expr = FilterExpr::parse("status!=A").unwrap();
376        assert_eq!(expr.field, "status");
377        assert_eq!(expr.op, FilterOp::Ne);
378        assert_eq!(expr.value, "A");
379    }
380
381    #[test]
382    fn test_op_validity_ne() {
383        // Test Ne validity for all types
384        assert!(FilterOp::Ne.valid_for(FieldType::String));
385        assert!(FilterOp::Ne.valid_for(FieldType::Date));
386        assert!(FilterOp::Ne.valid_for(FieldType::Char));
387    }
388}