1use rusqlite::Connection;
6
7use crate::error::Result;
8
9pub const SCHEMA_VERSION: i32 = 6;
11
12pub struct Schema;
14
15impl Schema {
16 pub fn create_tables(conn: &Connection) -> Result<()> {
18 conn.execute_batch("PRAGMA page_size = 1024;")?;
21
22 conn.execute_batch(
25 r#"
26 CREATE TABLE IF NOT EXISTS metadata (
27 key TEXT PRIMARY KEY,
28 value TEXT NOT NULL
29 ) WITHOUT ROWID;
30 "#,
31 )?;
32
33 conn.execute(
35 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
36 [&SCHEMA_VERSION.to_string()],
37 )?;
38
39 conn.execute_batch(
43 r#"
44 CREATE TABLE IF NOT EXISTS licenses (
45 unique_system_identifier INTEGER PRIMARY KEY,
46 uls_file_number TEXT,
47 ebf_number TEXT,
48 call_sign TEXT COLLATE NOCASE,
49 license_status INTEGER,
50 radio_service_code INTEGER,
51 grant_date TEXT,
52 expired_date TEXT,
53 cancellation_date TEXT,
54 effective_date TEXT,
55 last_action_date TEXT,
56 revoked_certification TEXT,
57 license_revoked TEXT,
58 licensee_name TEXT COLLATE NOCASE,
59 first_name TEXT COLLATE NOCASE,
60 middle_initial TEXT,
61 last_name TEXT COLLATE NOCASE,
62 suffix TEXT,
63 frn TEXT COLLATE NOCASE,
64 previous_call_sign TEXT COLLATE NOCASE,
65 trustee_call_sign TEXT COLLATE NOCASE,
66 trustee_name TEXT COLLATE NOCASE
67 ) WITHOUT ROWID;
68 "#,
69 )?;
70
71 conn.execute_batch(
76 r#"
77 CREATE TABLE IF NOT EXISTS entities (
78 id INTEGER PRIMARY KEY AUTOINCREMENT,
79 unique_system_identifier INTEGER NOT NULL,
80 uls_file_number TEXT,
81 ebf_number TEXT,
82 call_sign TEXT COLLATE NOCASE,
83 entity_type INTEGER,
84 licensee_id TEXT,
85 entity_name TEXT COLLATE NOCASE,
86 first_name TEXT COLLATE NOCASE,
87 middle_initial TEXT,
88 last_name TEXT COLLATE NOCASE,
89 suffix TEXT,
90 phone TEXT,
91 fax TEXT,
92 email TEXT,
93 street_address TEXT COLLATE NOCASE,
94 city TEXT COLLATE NOCASE,
95 state TEXT COLLATE NOCASE,
96 zip_code TEXT COLLATE NOCASE,
97 po_box TEXT,
98 attention_line TEXT,
99 sgin TEXT,
100 frn TEXT COLLATE NOCASE,
101 applicant_type_code TEXT,
102 status_code TEXT,
103 status_date TEXT,
104 UNIQUE(unique_system_identifier, entity_type),
105 FOREIGN KEY (unique_system_identifier) REFERENCES licenses(unique_system_identifier)
106 );
107 "#,
108 )?;
109
110 conn.execute_batch(
114 r#"
115 CREATE TABLE IF NOT EXISTS amateur_operators (
116 id INTEGER PRIMARY KEY AUTOINCREMENT,
117 unique_system_identifier INTEGER NOT NULL UNIQUE,
118 uls_file_number TEXT,
119 ebf_number TEXT,
120 call_sign TEXT COLLATE NOCASE,
121 operator_class INTEGER,
122 group_code TEXT,
123 region_code INTEGER,
124 trustee_call_sign TEXT COLLATE NOCASE,
125 trustee_indicator TEXT,
126 physician_certification TEXT,
127 ve_signature TEXT,
128 systematic_call_sign_change TEXT,
129 vanity_call_sign_change TEXT,
130 vanity_relationship TEXT,
131 previous_call_sign TEXT COLLATE NOCASE,
132 previous_operator_class INTEGER,
133 trustee_name TEXT COLLATE NOCASE,
134 FOREIGN KEY (unique_system_identifier) REFERENCES licenses(unique_system_identifier)
135 );
136 "#,
137 )?;
138
139 conn.execute_batch(
142 r#"
143 CREATE TABLE IF NOT EXISTS history (
144 id INTEGER PRIMARY KEY AUTOINCREMENT,
145 unique_system_identifier INTEGER NOT NULL,
146 uls_file_number TEXT,
147 callsign TEXT,
148 log_date TEXT,
149 code TEXT,
150 UNIQUE(unique_system_identifier, log_date, code),
151 FOREIGN KEY (unique_system_identifier) REFERENCES licenses(unique_system_identifier)
152 );
153 "#,
154 )?;
155
156 conn.execute_batch(
159 r#"
160 CREATE TABLE IF NOT EXISTS comments (
161 id INTEGER PRIMARY KEY AUTOINCREMENT,
162 unique_system_identifier INTEGER NOT NULL,
163 uls_file_number TEXT,
164 callsign TEXT,
165 comment_date TEXT,
166 description TEXT,
167 status_code TEXT,
168 status_date TEXT,
169 UNIQUE(unique_system_identifier, comment_date),
170 FOREIGN KEY (unique_system_identifier) REFERENCES licenses(unique_system_identifier)
171 );
172 "#,
173 )?;
174
175 conn.execute_batch(
178 r#"
179 CREATE TABLE IF NOT EXISTS special_conditions (
180 id INTEGER PRIMARY KEY AUTOINCREMENT,
181 unique_system_identifier INTEGER NOT NULL,
182 uls_file_number TEXT,
183 ebf_number TEXT,
184 callsign TEXT,
185 special_condition_type TEXT,
186 special_condition_code INTEGER,
187 status_code TEXT,
188 status_date TEXT,
189 UNIQUE(unique_system_identifier, special_condition_code),
190 FOREIGN KEY (unique_system_identifier) REFERENCES licenses(unique_system_identifier)
191 );
192 "#,
193 )?;
194
195 conn.execute_batch(
198 r#"
199 CREATE TABLE IF NOT EXISTS import_status (
200 radio_service_code TEXT NOT NULL,
201 record_type TEXT NOT NULL,
202 imported_at TEXT,
203 record_count INTEGER,
204 PRIMARY KEY (radio_service_code, record_type)
205 ) WITHOUT ROWID;
206 "#,
207 )?;
208
209 conn.execute_batch(
211 r#"
212 CREATE TABLE IF NOT EXISTS applied_patches (
213 radio_service_code TEXT NOT NULL,
214 patch_date TEXT NOT NULL,
215 patch_weekday TEXT NOT NULL,
216 applied_at TEXT NOT NULL,
217 etag TEXT,
218 record_count INTEGER,
219 PRIMARY KEY (radio_service_code, patch_date)
220 );
221 "#,
222 )?;
223
224 Ok(())
225 }
226
227 pub fn create_indexes(conn: &Connection) -> Result<()> {
229 conn.execute_batch(
230 r#"
231 -- License indexes
232 CREATE INDEX IF NOT EXISTS idx_licenses_call_sign ON licenses(call_sign);
233 CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(license_status);
234 CREATE INDEX IF NOT EXISTS idx_licenses_service ON licenses(radio_service_code);
235 CREATE INDEX IF NOT EXISTS idx_licenses_frn ON licenses(frn);
236 CREATE INDEX IF NOT EXISTS idx_licenses_name ON licenses(licensee_name);
237 CREATE INDEX IF NOT EXISTS idx_licenses_grant_date ON licenses(grant_date);
238 CREATE INDEX IF NOT EXISTS idx_licenses_expired_date ON licenses(expired_date);
239
240 -- Entity indexes
241 CREATE INDEX IF NOT EXISTS idx_entities_usi ON entities(unique_system_identifier);
242 CREATE INDEX IF NOT EXISTS idx_entities_call_sign ON entities(call_sign);
243 CREATE INDEX IF NOT EXISTS idx_entities_frn ON entities(frn);
244 CREATE INDEX IF NOT EXISTS idx_entities_city_state ON entities(city, state);
245 CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(entity_name);
246 CREATE INDEX IF NOT EXISTS idx_entities_last_name ON entities(last_name);
247
248 -- Amateur operator indexes
249 CREATE INDEX IF NOT EXISTS idx_amateur_usi ON amateur_operators(unique_system_identifier);
250 CREATE INDEX IF NOT EXISTS idx_amateur_call_sign ON amateur_operators(call_sign);
251 CREATE INDEX IF NOT EXISTS idx_amateur_class ON amateur_operators(operator_class);
252
253 -- History indexes
254 CREATE INDEX IF NOT EXISTS idx_history_usi ON history(unique_system_identifier);
255 CREATE INDEX IF NOT EXISTS idx_history_callsign ON history(callsign);
256
257 -- Comments indexes
258 CREATE INDEX IF NOT EXISTS idx_comments_usi ON comments(unique_system_identifier);
259 CREATE INDEX IF NOT EXISTS idx_comments_callsign ON comments(callsign);
260
261 -- Special conditions indexes
262 CREATE INDEX IF NOT EXISTS idx_special_cond_usi ON special_conditions(unique_system_identifier);
263 "#,
264 )?;
265
266 Ok(())
267 }
268
269 pub fn drop_indexes(conn: &Connection) -> Result<()> {
272 conn.execute_batch(
273 r#"
274 -- License indexes
275 DROP INDEX IF EXISTS idx_licenses_call_sign;
276 DROP INDEX IF EXISTS idx_licenses_status;
277 DROP INDEX IF EXISTS idx_licenses_service;
278 DROP INDEX IF EXISTS idx_licenses_frn;
279 DROP INDEX IF EXISTS idx_licenses_name;
280 DROP INDEX IF EXISTS idx_licenses_grant_date;
281 DROP INDEX IF EXISTS idx_licenses_expired_date;
282
283 -- Entity indexes
284 DROP INDEX IF EXISTS idx_entities_usi;
285 DROP INDEX IF EXISTS idx_entities_call_sign;
286 DROP INDEX IF EXISTS idx_entities_frn;
287 DROP INDEX IF EXISTS idx_entities_city_state;
288 DROP INDEX IF EXISTS idx_entities_name;
289 DROP INDEX IF EXISTS idx_entities_last_name;
290
291 -- Amateur operator indexes
292 DROP INDEX IF EXISTS idx_amateur_usi;
293 DROP INDEX IF EXISTS idx_amateur_call_sign;
294 DROP INDEX IF EXISTS idx_amateur_class;
295
296 -- History indexes
297 DROP INDEX IF EXISTS idx_history_usi;
298 DROP INDEX IF EXISTS idx_history_callsign;
299
300 -- Comments indexes
301 DROP INDEX IF EXISTS idx_comments_usi;
302 DROP INDEX IF EXISTS idx_comments_callsign;
303
304 -- Special conditions indexes
305 DROP INDEX IF EXISTS idx_special_cond_usi;
306 "#,
307 )?;
308
309 Ok(())
310 }
311
312 pub fn initialize(conn: &Connection) -> Result<()> {
314 Self::create_tables(conn)?;
315 Self::create_indexes(conn)?;
316 Ok(())
317 }
318
319 pub fn get_version(conn: &Connection) -> Result<Option<i32>> {
322 let result = conn.query_row(
323 "SELECT value FROM metadata WHERE key = 'schema_version'",
324 [],
325 |row| row.get::<_, String>(0),
326 );
327
328 match result {
329 Ok(v) => Ok(v.parse().ok()),
330 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
331 Err(rusqlite::Error::SqliteFailure(_, Some(ref msg)))
332 if msg.contains("no such table") =>
333 {
334 Ok(None)
336 }
337 Err(e) => Err(e.into()),
338 }
339 }
340
341 pub fn set_metadata(conn: &Connection, key: &str, value: &str) -> Result<()> {
343 conn.execute(
344 "INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)",
345 [key, value],
346 )?;
347 Ok(())
348 }
349
350 pub fn get_metadata(conn: &Connection, key: &str) -> Result<Option<String>> {
352 let result = conn.query_row("SELECT value FROM metadata WHERE key = ?1", [key], |row| {
353 row.get(0)
354 });
355
356 match result {
357 Ok(v) => Ok(Some(v)),
358 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
359 Err(e) => Err(e.into()),
360 }
361 }
362
363 pub fn migrate_if_needed(conn: &Connection) -> Result<()> {
367 let current_version = Self::get_version(conn)?;
368
369 match current_version {
370 None => {
371 Ok(())
373 }
374 Some(v) if v >= SCHEMA_VERSION => {
375 Ok(())
377 }
378 Some(v) => {
379 tracing::info!(
381 "Migrating database from schema v{} to v{}",
382 v,
383 SCHEMA_VERSION
384 );
385
386 if v < 5 {
388 Self::migrate_to_v5(conn)?;
389 }
390
391 conn.execute(
393 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
394 [&SCHEMA_VERSION.to_string()],
395 )?;
396
397 Ok(())
398 }
399 }
400 }
401
402 fn migrate_to_v5(conn: &Connection) -> Result<()> {
404 tracing::info!("Applying migration to v5: adding applied_patches table");
405
406 conn.execute_batch(
407 r#"
408 CREATE TABLE IF NOT EXISTS applied_patches (
409 id INTEGER PRIMARY KEY AUTOINCREMENT,
410 radio_service_code TEXT NOT NULL,
411 patch_date TEXT NOT NULL,
412 patch_weekday TEXT NOT NULL,
413 applied_at TEXT NOT NULL,
414 etag TEXT,
415 record_count INTEGER,
416 UNIQUE(radio_service_code, patch_date)
417 );
418
419 CREATE INDEX IF NOT EXISTS idx_applied_patches_service
420 ON applied_patches(radio_service_code);
421 "#,
422 )?;
423
424 Ok(())
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use rusqlite::Connection;
432
433 #[test]
434 fn test_create_schema() {
435 let conn = Connection::open_in_memory().unwrap();
436 Schema::initialize(&conn).unwrap();
437
438 let count: i32 = conn
440 .query_row(
441 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='licenses'",
442 [],
443 |row| row.get(0),
444 )
445 .unwrap();
446 assert_eq!(count, 1);
447
448 let version = Schema::get_version(&conn).unwrap();
450 assert_eq!(version, Some(SCHEMA_VERSION));
451 }
452
453 #[test]
454 fn test_metadata() {
455 let conn = Connection::open_in_memory().unwrap();
456 Schema::initialize(&conn).unwrap();
457
458 Schema::set_metadata(&conn, "test_key", "test_value").unwrap();
459 let value = Schema::get_metadata(&conn, "test_key").unwrap();
460 assert_eq!(value, Some("test_value".to_string()));
461
462 let missing = Schema::get_metadata(&conn, "nonexistent").unwrap();
463 assert_eq!(missing, None);
464 }
465
466 #[test]
467 fn test_unique_constraints() {
468 let conn = Connection::open_in_memory().unwrap();
469 Schema::initialize(&conn).unwrap();
470
471 conn.execute(
473 "INSERT INTO licenses (unique_system_identifier, call_sign) VALUES (1, 'W1AW')",
474 [],
475 )
476 .unwrap();
477
478 conn.execute(
480 "INSERT INTO entities (unique_system_identifier, entity_type, entity_name) VALUES (1, 'L', 'Test')",
481 [],
482 ).unwrap();
483
484 let result = conn.execute(
486 "INSERT INTO entities (unique_system_identifier, entity_type, entity_name) VALUES (1, 'L', 'Test2')",
487 [],
488 );
489 assert!(result.is_err());
490
491 conn.execute(
493 "INSERT INTO entities (unique_system_identifier, entity_type, entity_name) VALUES (1, 'C', 'Contact')",
494 [],
495 ).unwrap();
496 }
497
498 #[test]
499 fn test_drop_and_recreate_indexes() {
500 let conn = Connection::open_in_memory().unwrap();
501 Schema::initialize(&conn).unwrap();
502
503 let count_before: i32 = conn
505 .query_row(
506 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
507 [],
508 |row| row.get(0),
509 )
510 .unwrap();
511 assert!(count_before > 0, "Should have indexes after initialize");
512
513 Schema::drop_indexes(&conn).unwrap();
515
516 let count_after_drop: i32 = conn
518 .query_row(
519 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
520 [],
521 |row| row.get(0),
522 )
523 .unwrap();
524 assert_eq!(count_after_drop, 0, "All indexes should be dropped");
525
526 Schema::create_indexes(&conn).unwrap();
528
529 let count_after_recreate: i32 = conn
531 .query_row(
532 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
533 [],
534 |row| row.get(0),
535 )
536 .unwrap();
537 assert_eq!(
538 count_before, count_after_recreate,
539 "All indexes should be recreated"
540 );
541 }
542}