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,
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, 17)?;
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                operator_class,
126            })
127        })?;
128
129        let mut results = Vec::new();
130        for license in iter {
131            results.push(license?);
132        }
133
134        Ok(results)
135    }
136
137    /// Get database statistics.
138    pub fn stats(&self) -> Result<LicenseStats> {
139        Ok(self.db.get_stats()?)
140    }
141
142    /// Check if the database is ready for queries.
143    pub fn is_ready(&self) -> Result<bool> {
144        self.db.is_initialized().map_err(Into::into)
145    }
146
147    /// Get the count of results for a filter without fetching all data.
148    pub fn count(&self, filter: SearchFilter) -> Result<usize> {
149        let (where_clause, params) = filter.to_where_clause();
150
151        let query = format!(
152            r#"
153            SELECT COUNT(*)
154            FROM licenses l
155            LEFT JOIN entities e ON l.unique_system_identifier = e.unique_system_identifier
156            LEFT JOIN amateur_operators a ON l.unique_system_identifier = a.unique_system_identifier
157            WHERE {}
158            "#,
159            where_clause
160        );
161
162        let conn = self.db.conn()?;
163        let count: usize =
164            conn.query_row(&query, params_from_iter(params), |row| row.get::<_, i64>(0))? as usize;
165        Ok(count)
166    }
167
168    /// Get the underlying database reference.
169    pub fn database(&self) -> &Database {
170        &self.db
171    }
172
173    // ========================================================================
174    // Lazy Loading Support
175    // ========================================================================
176
177    /// Determine which record types are required for basic queries.
178    ///
179    /// Returns the minimal set of record types needed:
180    /// - HD (licenses) - always needed
181    /// - EN (entities) - needed for name/address/FRN
182    /// - AM (amateur) - needed if operator_class filter is used
183    pub fn required_record_types(filter: &SearchFilter) -> Vec<&'static str> {
184        let mut types = vec!["HD", "EN"];
185        if filter.operator_class.is_some() {
186            types.push("AM");
187        }
188        types
189    }
190
191    /// Check if any required record types are missing for a given service.
192    ///
193    /// Returns a list of missing record types that need to be imported.
194    pub fn missing_data_for_query(
195        &self,
196        service: &str,
197        filter: &SearchFilter,
198    ) -> Result<Vec<String>> {
199        let required = Self::required_record_types(filter);
200        let mut missing = Vec::new();
201
202        for record_type in required {
203            if !self.db.has_record_type(service, record_type)? {
204                missing.push(record_type.to_string());
205            }
206        }
207
208        Ok(missing)
209    }
210
211    /// Check if data is available for basic queries (HD + EN at minimum).
212    pub fn has_basic_data(&self, service: &str) -> Result<bool> {
213        let has_hd = self.db.has_record_type(service, "HD")?;
214        let has_en = self.db.has_record_type(service, "EN")?;
215        Ok(has_hd && has_en)
216    }
217
218    /// Get the list of imported record types for a service.
219    pub fn imported_types(&self, service: &str) -> Result<Vec<String>> {
220        Ok(self.db.get_imported_types(service)?)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_query_engine_with_initialized_db() {
230        let config = DatabaseConfig::in_memory();
231        let db = Database::with_config(config).unwrap();
232        db.initialize().unwrap();
233
234        let engine = QueryEngine::with_database(db);
235        assert!(engine.is_ready().unwrap());
236    }
237
238    #[test]
239    fn test_query_engine_not_initialized() {
240        let config = DatabaseConfig::in_memory();
241        let db = Database::with_config(config).unwrap();
242        // Don't initialize - should return false, not error
243
244        let engine = QueryEngine::with_database(db);
245        assert!(!engine.is_ready().unwrap());
246    }
247
248    #[test]
249    fn test_lookup_missing() {
250        let config = DatabaseConfig::in_memory();
251        let db = Database::with_config(config).unwrap();
252        db.initialize().unwrap();
253
254        let engine = QueryEngine::with_database(db);
255        let result = engine.lookup("NONEXISTENT").unwrap();
256        assert!(result.is_none());
257    }
258
259    #[test]
260    fn test_search_empty_db() {
261        let config = DatabaseConfig::in_memory();
262        let db = Database::with_config(config).unwrap();
263        db.initialize().unwrap();
264
265        let engine = QueryEngine::with_database(db);
266        let filter = SearchFilter::default();
267        let results = engine.search(filter).unwrap();
268        assert!(results.is_empty());
269    }
270
271    #[test]
272    fn test_count_empty_db() {
273        let config = DatabaseConfig::in_memory();
274        let db = Database::with_config(config).unwrap();
275        db.initialize().unwrap();
276
277        let engine = QueryEngine::with_database(db);
278        let filter = SearchFilter::default();
279        let count = engine.count(filter).unwrap();
280        assert_eq!(count, 0);
281    }
282
283    #[test]
284    fn test_stats() {
285        let config = DatabaseConfig::in_memory();
286        let db = Database::with_config(config).unwrap();
287        db.initialize().unwrap();
288
289        let engine = QueryEngine::with_database(db);
290        let stats = engine.stats().unwrap();
291        assert_eq!(stats.total_licenses, 0);
292    }
293
294    #[test]
295    fn test_lookup_by_frn_empty() {
296        let config = DatabaseConfig::in_memory();
297        let db = Database::with_config(config).unwrap();
298        db.initialize().unwrap();
299
300        let engine = QueryEngine::with_database(db);
301        let results = engine.lookup_by_frn("0001234567").unwrap();
302        assert!(results.is_empty());
303    }
304
305    #[test]
306    fn test_database_accessor() {
307        let config = DatabaseConfig::in_memory();
308        let db = Database::with_config(config).unwrap();
309        db.initialize().unwrap();
310
311        let engine = QueryEngine::with_database(db);
312        assert!(engine.database().is_initialized().unwrap());
313    }
314
315    #[test]
316    fn test_search_with_data() {
317        use uls_core::records::{HeaderRecord, UlsRecord};
318
319        let config = DatabaseConfig::in_memory();
320        let db = Database::with_config(config).unwrap();
321        db.initialize().unwrap();
322
323        // Insert a test license
324        let mut header = HeaderRecord::from_fields(&["HD", "12345"]);
325        header.unique_system_identifier = 12345;
326        header.call_sign = Some("W1TEST".to_string());
327        header.license_status = Some('A');
328        header.radio_service_code = Some("HA".to_string());
329        db.insert_record(&UlsRecord::Header(header)).unwrap();
330
331        let engine = QueryEngine::with_database(db);
332
333        // Search with callsign filter
334        let filter = SearchFilter::callsign("W1TEST");
335        let results = engine.search(filter).unwrap();
336        assert_eq!(results.len(), 1);
337        assert_eq!(results[0].call_sign, "W1TEST");
338
339        // Count should match
340        let filter = SearchFilter::callsign("W1TEST");
341        let count = engine.count(filter).unwrap();
342        assert_eq!(count, 1);
343    }
344
345    #[test]
346    fn test_required_record_types_basic() {
347        let filter = SearchFilter::default();
348        let types = QueryEngine::required_record_types(&filter);
349        assert_eq!(types, vec!["HD", "EN"]);
350    }
351
352    #[test]
353    fn test_required_record_types_with_operator_class() {
354        let filter = SearchFilter::new().with_operator_class('E');
355        let types = QueryEngine::required_record_types(&filter);
356        assert_eq!(types, vec!["HD", "EN", "AM"]);
357    }
358
359    #[test]
360    fn test_has_basic_data_empty_db() {
361        let config = DatabaseConfig::in_memory();
362        let db = Database::with_config(config).unwrap();
363        db.initialize().unwrap();
364
365        let engine = QueryEngine::with_database(db);
366        // Empty database has no record types
367        let has_data = engine.has_basic_data("HA").unwrap();
368        assert!(!has_data);
369    }
370
371    #[test]
372    fn test_imported_types_empty_db() {
373        let config = DatabaseConfig::in_memory();
374        let db = Database::with_config(config).unwrap();
375        db.initialize().unwrap();
376
377        let engine = QueryEngine::with_database(db);
378        let types = engine.imported_types("HA").unwrap();
379        assert!(types.is_empty());
380    }
381
382    #[test]
383    fn test_missing_data_for_query_empty_db() {
384        let config = DatabaseConfig::in_memory();
385        let db = Database::with_config(config).unwrap();
386        db.initialize().unwrap();
387
388        let engine = QueryEngine::with_database(db);
389        let filter = SearchFilter::default();
390        let missing = engine.missing_data_for_query("HA", &filter).unwrap();
391        // Should be missing HD and EN since db is empty
392        assert!(missing.contains(&"HD".to_string()));
393        assert!(missing.contains(&"EN".to_string()));
394    }
395
396    #[test]
397    fn test_missing_data_for_query_with_operator_class() {
398        let config = DatabaseConfig::in_memory();
399        let db = Database::with_config(config).unwrap();
400        db.initialize().unwrap();
401
402        let engine = QueryEngine::with_database(db);
403        let filter = SearchFilter::new().with_operator_class('E');
404        let missing = engine.missing_data_for_query("HA", &filter).unwrap();
405        // Should be missing HD, EN, and AM
406        assert!(missing.contains(&"HD".to_string()));
407        assert!(missing.contains(&"EN".to_string()));
408        assert!(missing.contains(&"AM".to_string()));
409    }
410}