1use 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#[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
27pub type Result<T> = std::result::Result<T, QueryError>;
29
30pub struct QueryEngine {
32 db: Database,
33}
34
35impl QueryEngine {
36 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 pub fn with_database(db: Database) -> Self {
50 Self { db }
51 }
52
53 pub fn lookup(&self, callsign: &str) -> Result<Option<License>> {
55 Ok(self.db.get_license_by_callsign(callsign)?)
56 }
57
58 pub fn lookup_by_frn(&self, frn: &str) -> Result<Vec<License>> {
60 Ok(self.db.get_licenses_by_frn(frn)?)
61 }
62
63 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 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 pub fn stats(&self) -> Result<LicenseStats> {
140 Ok(self.db.get_stats()?)
141 }
142
143 pub fn is_ready(&self) -> Result<bool> {
145 self.db.is_initialized().map_err(Into::into)
146 }
147
148 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 pub fn database(&self) -> &Database {
171 &self.db
172 }
173
174 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 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 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 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 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 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 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 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 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 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 assert!(missing.contains(&"HD".to_string()));
408 assert!(missing.contains(&"EN".to_string()));
409 assert!(missing.contains(&"AM".to_string()));
410 }
411}