Skip to main content

uls_query/
engine.rs

1//! Query engine for license lookups and searches.
2
3use std::path::Path;
4
5use rusqlite::params_from_iter;
6use tracing::debug;
7
8use uls_db::enum_adapters::{read_license_status, read_operator_class, read_radio_service};
9use uls_db::{Database, DatabaseConfig};
10
11use crate::filter::SearchFilter;
12use uls_db::models::{License, LicenseStats};
13
14/// Errors from query operations.
15#[derive(Debug, thiserror::Error)]
16pub enum QueryError {
17    #[error("database error: {0}")]
18    Database(#[from] uls_db::DbError),
19
20    #[error("database not initialized - run 'uls update' first")]
21    NotInitialized,
22
23    #[error("SQLite error: {0}")]
24    Sqlite(#[from] rusqlite::Error),
25}
26
27/// Result type for query operations.
28pub type Result<T> = std::result::Result<T, QueryError>;
29
30/// Query engine for ULS data.
31pub struct QueryEngine {
32    db: Database,
33}
34
35impl QueryEngine {
36    /// Open a query engine with the given database path.
37    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
38        let config = DatabaseConfig::with_path(path.as_ref());
39        let db = Database::with_config(config)?;
40
41        if !db.is_initialized()? {
42            return Err(QueryError::NotInitialized);
43        }
44
45        Ok(Self { db })
46    }
47
48    /// Create a query engine with an existing database.
49    pub fn with_database(db: Database) -> Self {
50        Self { db }
51    }
52
53    /// Look up a license by callsign.
54    pub fn lookup(&self, callsign: &str) -> Result<Option<License>> {
55        Ok(self.db.get_license_by_callsign(callsign)?)
56    }
57
58    /// Look up all licenses by FRN (FCC Registration Number).
59    pub fn lookup_by_frn(&self, frn: &str) -> Result<Vec<License>> {
60        Ok(self.db.get_licenses_by_frn(frn)?)
61    }
62
63    /// Search for licenses matching the given filter.
64    pub fn search(&self, filter: SearchFilter) -> Result<Vec<License>> {
65        let (where_clause, params) = filter.to_where_clause();
66        let order_clause = filter.order_clause();
67        let limit_clause = filter.limit_clause();
68
69        let query = format!(
70            r#"
71            SELECT
72                l.unique_system_identifier, l.call_sign,
73                e.entity_name, e.first_name, e.middle_initial, e.last_name,
74                l.license_status, l.radio_service_code,
75                l.grant_date, l.expired_date, l.cancellation_date,
76                e.frn, NULL as previous_call_sign,
77                e.street_address, e.city, e.state, e.zip_code, e.po_box,
78                a.operator_class
79            FROM licenses l
80            LEFT JOIN entities e ON l.unique_system_identifier = e.unique_system_identifier
81            LEFT JOIN amateur_operators a ON l.unique_system_identifier = a.unique_system_identifier
82            WHERE {}
83            {}
84            {}
85            "#,
86            where_clause, order_clause, limit_clause
87        );
88
89        debug!("Search query: {}", query);
90        debug!("Params: {:?}", params);
91
92        let conn = self.db.conn()?;
93
94        let mut stmt = conn.prepare(&query)?;
95        let iter = stmt.query_map(params_from_iter(params), |row| {
96            // Use centralized enum adapter helpers from uls-db
97            let status = read_license_status(row, 6)?;
98            let radio_service = read_radio_service(row, 7)?;
99            let operator_class = read_operator_class(row, 18)?;
100
101            Ok(License {
102                unique_system_identifier: row.get(0)?,
103                call_sign: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
104                licensee_name: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
105                first_name: row.get(3)?,
106                middle_initial: row.get(4)?,
107                last_name: row.get(5)?,
108                status,
109                radio_service,
110                grant_date: row
111                    .get::<_, Option<String>>(8)?
112                    .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
113                expired_date: row
114                    .get::<_, Option<String>>(9)?
115                    .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
116                cancellation_date: row
117                    .get::<_, Option<String>>(10)?
118                    .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
119                frn: row.get(11)?,
120                previous_call_sign: row.get(12)?,
121                street_address: row.get(13)?,
122                city: row.get(14)?,
123                state: row.get(15)?,
124                zip_code: row.get(16)?,
125                po_box: row.get(17)?,
126                operator_class,
127            })
128        })?;
129
130        let mut results = Vec::new();
131        for license in iter {
132            results.push(license?);
133        }
134
135        Ok(results)
136    }
137
138    /// Get database statistics.
139    pub fn stats(&self) -> Result<LicenseStats> {
140        Ok(self.db.get_stats()?)
141    }
142
143    /// Check if the database is ready for queries.
144    pub fn is_ready(&self) -> Result<bool> {
145        self.db.is_initialized().map_err(Into::into)
146    }
147
148    /// Get the count of results for a filter without fetching all data.
149    pub fn count(&self, filter: SearchFilter) -> Result<usize> {
150        let (where_clause, params) = filter.to_where_clause();
151
152        let query = format!(
153            r#"
154            SELECT COUNT(*)
155            FROM licenses l
156            LEFT JOIN entities e ON l.unique_system_identifier = e.unique_system_identifier
157            LEFT JOIN amateur_operators a ON l.unique_system_identifier = a.unique_system_identifier
158            WHERE {}
159            "#,
160            where_clause
161        );
162
163        let conn = self.db.conn()?;
164        let count: usize =
165            conn.query_row(&query, params_from_iter(params), |row| row.get::<_, i64>(0))? as usize;
166        Ok(count)
167    }
168
169    /// Get the underlying database reference.
170    pub fn database(&self) -> &Database {
171        &self.db
172    }
173
174    // ========================================================================
175    // Lazy Loading Support
176    // ========================================================================
177
178    /// Determine which record types are required for basic queries.
179    ///
180    /// Returns the minimal set of record types needed:
181    /// - HD (licenses) - always needed
182    /// - EN (entities) - needed for name/address/FRN
183    /// - AM (amateur) - needed if operator_class filter is used
184    pub fn required_record_types(filter: &SearchFilter) -> Vec<&'static str> {
185        let mut types = vec!["HD", "EN"];
186        if filter.operator_class.is_some() {
187            types.push("AM");
188        }
189        types
190    }
191
192    /// Check if any required record types are missing for a given service.
193    ///
194    /// Returns a list of missing record types that need to be imported.
195    pub fn missing_data_for_query(
196        &self,
197        service: &str,
198        filter: &SearchFilter,
199    ) -> Result<Vec<String>> {
200        let required = Self::required_record_types(filter);
201        let mut missing = Vec::new();
202
203        for record_type in required {
204            if !self.db.has_record_type(service, record_type)? {
205                missing.push(record_type.to_string());
206            }
207        }
208
209        Ok(missing)
210    }
211
212    /// Check if data is available for basic queries (HD + EN at minimum).
213    pub fn has_basic_data(&self, service: &str) -> Result<bool> {
214        let has_hd = self.db.has_record_type(service, "HD")?;
215        let has_en = self.db.has_record_type(service, "EN")?;
216        Ok(has_hd && has_en)
217    }
218
219    /// Get the list of imported record types for a service.
220    pub fn imported_types(&self, service: &str) -> Result<Vec<String>> {
221        Ok(self.db.get_imported_types(service)?)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_query_engine_with_initialized_db() {
231        let config = DatabaseConfig::in_memory();
232        let db = Database::with_config(config).unwrap();
233        db.initialize().unwrap();
234
235        let engine = QueryEngine::with_database(db);
236        assert!(engine.is_ready().unwrap());
237    }
238
239    #[test]
240    fn test_query_engine_not_initialized() {
241        let config = DatabaseConfig::in_memory();
242        let db = Database::with_config(config).unwrap();
243        // Don't initialize - should return false, not error
244
245        let engine = QueryEngine::with_database(db);
246        assert!(!engine.is_ready().unwrap());
247    }
248
249    #[test]
250    fn test_lookup_missing() {
251        let config = DatabaseConfig::in_memory();
252        let db = Database::with_config(config).unwrap();
253        db.initialize().unwrap();
254
255        let engine = QueryEngine::with_database(db);
256        let result = engine.lookup("NONEXISTENT").unwrap();
257        assert!(result.is_none());
258    }
259
260    #[test]
261    fn test_search_empty_db() {
262        let config = DatabaseConfig::in_memory();
263        let db = Database::with_config(config).unwrap();
264        db.initialize().unwrap();
265
266        let engine = QueryEngine::with_database(db);
267        let filter = SearchFilter::default();
268        let results = engine.search(filter).unwrap();
269        assert!(results.is_empty());
270    }
271
272    #[test]
273    fn test_count_empty_db() {
274        let config = DatabaseConfig::in_memory();
275        let db = Database::with_config(config).unwrap();
276        db.initialize().unwrap();
277
278        let engine = QueryEngine::with_database(db);
279        let filter = SearchFilter::default();
280        let count = engine.count(filter).unwrap();
281        assert_eq!(count, 0);
282    }
283
284    #[test]
285    fn test_stats() {
286        let config = DatabaseConfig::in_memory();
287        let db = Database::with_config(config).unwrap();
288        db.initialize().unwrap();
289
290        let engine = QueryEngine::with_database(db);
291        let stats = engine.stats().unwrap();
292        assert_eq!(stats.total_licenses, 0);
293    }
294
295    #[test]
296    fn test_lookup_by_frn_empty() {
297        let config = DatabaseConfig::in_memory();
298        let db = Database::with_config(config).unwrap();
299        db.initialize().unwrap();
300
301        let engine = QueryEngine::with_database(db);
302        let results = engine.lookup_by_frn("0001234567").unwrap();
303        assert!(results.is_empty());
304    }
305
306    #[test]
307    fn test_database_accessor() {
308        let config = DatabaseConfig::in_memory();
309        let db = Database::with_config(config).unwrap();
310        db.initialize().unwrap();
311
312        let engine = QueryEngine::with_database(db);
313        assert!(engine.database().is_initialized().unwrap());
314    }
315
316    #[test]
317    fn test_search_with_data() {
318        use uls_core::records::{HeaderRecord, UlsRecord};
319
320        let config = DatabaseConfig::in_memory();
321        let db = Database::with_config(config).unwrap();
322        db.initialize().unwrap();
323
324        // Insert a test license
325        let mut header = HeaderRecord::from_fields(&["HD", "12345"]);
326        header.unique_system_identifier = 12345;
327        header.call_sign = Some("W1TEST".to_string());
328        header.license_status = Some('A');
329        header.radio_service_code = Some("HA".to_string());
330        db.insert_record(&UlsRecord::Header(header)).unwrap();
331
332        let engine = QueryEngine::with_database(db);
333
334        // Search with callsign filter
335        let filter = SearchFilter::callsign("W1TEST");
336        let results = engine.search(filter).unwrap();
337        assert_eq!(results.len(), 1);
338        assert_eq!(results[0].call_sign, "W1TEST");
339
340        // Count should match
341        let filter = SearchFilter::callsign("W1TEST");
342        let count = engine.count(filter).unwrap();
343        assert_eq!(count, 1);
344    }
345
346    #[test]
347    fn test_required_record_types_basic() {
348        let filter = SearchFilter::default();
349        let types = QueryEngine::required_record_types(&filter);
350        assert_eq!(types, vec!["HD", "EN"]);
351    }
352
353    #[test]
354    fn test_required_record_types_with_operator_class() {
355        let filter = SearchFilter::new().with_operator_class('E');
356        let types = QueryEngine::required_record_types(&filter);
357        assert_eq!(types, vec!["HD", "EN", "AM"]);
358    }
359
360    #[test]
361    fn test_has_basic_data_empty_db() {
362        let config = DatabaseConfig::in_memory();
363        let db = Database::with_config(config).unwrap();
364        db.initialize().unwrap();
365
366        let engine = QueryEngine::with_database(db);
367        // Empty database has no record types
368        let has_data = engine.has_basic_data("HA").unwrap();
369        assert!(!has_data);
370    }
371
372    #[test]
373    fn test_imported_types_empty_db() {
374        let config = DatabaseConfig::in_memory();
375        let db = Database::with_config(config).unwrap();
376        db.initialize().unwrap();
377
378        let engine = QueryEngine::with_database(db);
379        let types = engine.imported_types("HA").unwrap();
380        assert!(types.is_empty());
381    }
382
383    #[test]
384    fn test_missing_data_for_query_empty_db() {
385        let config = DatabaseConfig::in_memory();
386        let db = Database::with_config(config).unwrap();
387        db.initialize().unwrap();
388
389        let engine = QueryEngine::with_database(db);
390        let filter = SearchFilter::default();
391        let missing = engine.missing_data_for_query("HA", &filter).unwrap();
392        // Should be missing HD and EN since db is empty
393        assert!(missing.contains(&"HD".to_string()));
394        assert!(missing.contains(&"EN".to_string()));
395    }
396
397    #[test]
398    fn test_missing_data_for_query_with_operator_class() {
399        let config = DatabaseConfig::in_memory();
400        let db = Database::with_config(config).unwrap();
401        db.initialize().unwrap();
402
403        let engine = QueryEngine::with_database(db);
404        let filter = SearchFilter::new().with_operator_class('E');
405        let missing = engine.missing_data_for_query("HA", &filter).unwrap();
406        // Should be missing HD, EN, and AM
407        assert!(missing.contains(&"HD".to_string()));
408        assert!(missing.contains(&"EN".to_string()));
409        assert!(missing.contains(&"AM".to_string()));
410    }
411}