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,
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, 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 pub fn stats(&self) -> Result<LicenseStats> {
139 Ok(self.db.get_stats()?)
140 }
141
142 pub fn is_ready(&self) -> Result<bool> {
144 self.db.is_initialized().map_err(Into::into)
145 }
146
147 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 pub fn database(&self) -> &Database {
170 &self.db
171 }
172
173 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 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 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 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 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 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 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 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 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 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 assert!(missing.contains(&"HD".to_string()));
407 assert!(missing.contains(&"EN".to_string()));
408 assert!(missing.contains(&"AM".to_string()));
409 }
410}