1use rusqlite::{Connection, Result as SqliteResult};
4
5pub const MAX_KNOWN_SCHEMA_VERSION: i32 = 28;
13
14pub fn get_schema_version(conn: &Connection) -> SqliteResult<i32> {
16 let result = conn.query_row("SELECT MAX(version) FROM schema_version", [], |row| {
17 row.get(0)
18 });
19
20 match result {
21 Ok(version) => Ok(version),
22 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
23 Err(e) => Err(e),
24 }
25}
26
27#[derive(Debug)]
31pub struct SchemaTooNewError {
32 pub db_version: i32,
33 pub max_supported: i32,
34}
35
36impl std::fmt::Display for SchemaTooNewError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 write!(
39 f,
40 "Library was created by a newer version of Smriti \
41 (schema v{}). This build only supports up to v{}. \
42 Please update Smriti to open this library.",
43 self.db_version, self.max_supported
44 )
45 }
46}
47
48impl std::error::Error for SchemaTooNewError {}
49
50pub fn run_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
52 let current_version = get_schema_version(conn).unwrap_or(0);
53
54 if current_version > MAX_KNOWN_SCHEMA_VERSION {
58 return Err(Box::new(SchemaTooNewError {
59 db_version: current_version,
60 max_supported: MAX_KNOWN_SCHEMA_VERSION,
61 }));
62 }
63
64 if current_version < 2 {
65 migrate_v1_to_v2(conn)?;
66 }
67 if current_version < 3 {
68 migrate_v2_to_v3(conn)?;
69 }
70 if current_version < 4 {
71 migrate_v3_to_v4(conn)?;
72 }
73 if current_version < 5 {
74 migrate_v4_to_v5(conn)?;
75 }
76 if current_version < 6 {
77 migrate_v5_to_v6(conn)?;
78 }
79 if current_version < 7 {
80 migrate_v6_to_v7(conn)?;
81 }
82 if current_version < 8 {
83 migrate_v7_to_v8(conn)?;
84 }
85 if current_version < 9 {
86 migrate_v8_to_v9(conn)?;
87 }
88 if current_version < 10 {
89 migrate_v9_to_v10(conn)?;
90 }
91 if current_version < 11 {
92 migrate_v10_to_v11(conn)?;
93 }
94 if current_version < 12 {
95 migrate_v11_to_v12(conn)?;
96 }
97 if current_version < 13 {
98 migrate_v12_to_v13(conn)?;
99 }
100 if current_version < 14 {
101 migrate_v13_to_v14(conn)?;
102 }
103 if current_version < 15 {
104 migrate_v14_to_v15(conn)?;
105 }
106 if current_version < 16 {
107 migrate_v15_to_v16(conn)?;
108 }
109 if current_version < 17 {
110 migrate_v16_to_v17(conn)?;
111 }
112 if current_version < 18 {
113 migrate_v17_to_v18(conn)?;
114 }
115 if current_version < 19 {
116 migrate_v18_to_v19(conn)?;
117 }
118 if current_version < 20 {
119 migrate_v19_to_v20(conn)?;
120 }
121 if current_version < 21 {
122 migrate_v20_to_v21(conn)?;
123 }
124 if current_version < 22 {
125 migrate_v21_to_v22(conn)?;
126 }
127 if current_version < 23 {
128 migrate_v22_to_v23(conn)?;
129 }
130 if current_version < 24 {
131 migrate_v23_to_v24(conn)?;
132 }
133 if current_version < 25 {
134 migrate_v24_to_v25(conn)?;
135 }
136 if current_version < 26 {
137 migrate_v25_to_v26(conn)?;
138 }
139 if current_version < 27 {
140 migrate_v26_to_v27(conn)?;
141 }
142 if current_version < 28 {
143 migrate_v27_to_v28(conn)?;
144 }
145 ensure_performance_indexes(conn)?;
146 let updated_version = get_schema_version(conn).unwrap_or(current_version);
147 tracing::info!("Database at schema version {}", updated_version);
148 Ok(())
149}
150
151fn migrate_v27_to_v28(conn: &Connection) -> SqliteResult<()> {
152 let tx = conn.unchecked_transaction()?;
153 tx.execute_batch(
154 r#"
155 CREATE TABLE IF NOT EXISTS google_takeout_items (
156 content_hash TEXT PRIMARY KEY,
157 file_path TEXT NOT NULL UNIQUE,
158 metadata_json TEXT,
159 imported_at DATETIME DEFAULT CURRENT_TIMESTAMP,
160 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
161 );
162
163 CREATE TABLE IF NOT EXISTS google_takeout_albums (
164 content_hash TEXT NOT NULL,
165 album_name TEXT NOT NULL,
166 PRIMARY KEY (content_hash, album_name),
167 FOREIGN KEY (content_hash) REFERENCES google_takeout_items(content_hash)
168 ON DELETE CASCADE
169 );
170
171 CREATE INDEX IF NOT EXISTS idx_google_takeout_items_path
172 ON google_takeout_items(file_path);
173
174 INSERT INTO schema_version (version) VALUES (28);
175 "#,
176 )?;
177 tx.commit()?;
178 tracing::info!("Migrated database to schema version 28 (Google Takeout imports)");
179 Ok(())
180}
181
182fn ensure_performance_indexes(conn: &Connection) -> SqliteResult<()> {
183 conn.execute_batch(
184 r#"
185 CREATE INDEX IF NOT EXISTS idx_photos_timeline_order
186 ON photos(is_trashed, (date_taken IS NULL), date_taken DESC, id DESC);
187 CREATE INDEX IF NOT EXISTS idx_photos_favorite_order
188 ON photos(is_favorite, is_trashed, (date_taken IS NULL), date_taken DESC, id DESC)
189 WHERE is_favorite = TRUE;
190 CREATE INDEX IF NOT EXISTS idx_photos_gps_bounds
191 ON photos(is_trashed, gps_latitude, gps_longitude)
192 WHERE gps_latitude IS NOT NULL AND gps_longitude IS NOT NULL;
193 CREATE INDEX IF NOT EXISTS idx_faces_review_pending
194 ON faces(user_confirmed, cluster_id, id)
195 WHERE user_confirmed = 0 AND cluster_id IS NOT NULL;
196 "#,
197 )
198}
199
200fn migrate_v26_to_v27(conn: &Connection) -> SqliteResult<()> {
201 let tx = conn.unchecked_transaction()?;
202 tx.execute_batch(
203 r#"
204 CREATE TABLE IF NOT EXISTS albums (
205 id INTEGER PRIMARY KEY,
206 name TEXT NOT NULL,
207 cover_photo_id INTEGER,
208 cover_auto_picked BOOLEAN DEFAULT TRUE,
209 photo_count INTEGER DEFAULT 0,
210 created_by TEXT NOT NULL DEFAULT 'user' CHECK(created_by IN ('user', 'agent')),
211 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
212 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
213 FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL
214 );
215
216 CREATE TABLE IF NOT EXISTS album_photos (
217 id INTEGER PRIMARY KEY,
218 album_id INTEGER NOT NULL,
219 photo_id INTEGER NOT NULL,
220 added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
221 FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE,
222 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
223 UNIQUE(album_id, photo_id)
224 );
225
226 CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id);
227 CREATE INDEX IF NOT EXISTS idx_album_photos_photo ON album_photos(photo_id);
228 "#,
229 )?;
230 match tx.execute(
231 "ALTER TABLE albums ADD COLUMN created_by TEXT NOT NULL DEFAULT 'user' CHECK(created_by IN ('user', 'agent'))",
232 [],
233 ) {
234 Ok(_) => {}
235 Err(e) => {
236 let msg = e.to_string();
237 if !msg.contains("duplicate column") {
238 return Err(e);
239 }
240 }
241 }
242 tx.execute("INSERT INTO schema_version (version) VALUES (27)", [])?;
243 tx.commit()?;
244 tracing::info!("Migrated database to schema version 27 (album provenance)");
245 Ok(())
246}
247
248fn migrate_v25_to_v26(conn: &Connection) -> SqliteResult<()> {
249 let tx = conn.unchecked_transaction()?;
250 tx.execute_batch(
251 r#"
252 CREATE TABLE IF NOT EXISTS semantic_index_state (
253 photo_id INTEGER NOT NULL,
254 model_key TEXT NOT NULL,
255 status TEXT NOT NULL DEFAULT 'pending'
256 CHECK(status IN ('pending', 'indexed', 'failed', 'unsupported')),
257 vector_offset INTEGER,
258 vector_dim INTEGER,
259 attempts INTEGER NOT NULL DEFAULT 0,
260 last_error TEXT,
261 indexed_at DATETIME,
262 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
263 PRIMARY KEY (photo_id, model_key),
264 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE
265 );
266
267 CREATE INDEX IF NOT EXISTS idx_semantic_state_status
268 ON semantic_index_state(model_key, status);
269 CREATE INDEX IF NOT EXISTS idx_semantic_state_photo
270 ON semantic_index_state(photo_id);
271
272 INSERT INTO schema_version (version) VALUES (26);
273 "#,
274 )?;
275 tx.commit()?;
276 tracing::info!("Migrated database to schema version 26 (semantic search)");
277 Ok(())
278}
279
280fn migrate_v24_to_v25(conn: &Connection) -> SqliteResult<()> {
281 let tx = conn.unchecked_transaction()?;
282 tx.execute_batch(
283 r#"
284 CREATE TABLE IF NOT EXISTS excluded_folders (
285 relative_path TEXT PRIMARY KEY,
286 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
287 );
288
289 INSERT INTO schema_version (version) VALUES (25);
290 "#,
291 )?;
292 tx.commit()?;
293 tracing::info!("Migrated database to schema version 25 (excluded folders)");
294 Ok(())
295}
296
297fn migrate_v23_to_v24(conn: &Connection) -> SqliteResult<()> {
298 let tx = conn.unchecked_transaction()?;
299 match tx.execute(
300 "ALTER TABLE photos ADD COLUMN is_favorite BOOLEAN DEFAULT FALSE",
301 [],
302 ) {
303 Ok(_) => {}
304 Err(e) => {
305 let msg = e.to_string();
306 if !msg.contains("duplicate column") {
307 return Err(e);
308 }
309 }
310 }
311 tx.execute(
312 "CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(is_favorite, date_taken DESC) WHERE is_favorite = TRUE",
313 [],
314 )?;
315 tx.execute("INSERT INTO schema_version (version) VALUES (24)", [])?;
316 tx.commit()?;
317 tracing::info!("Migrated database to schema version 24 (favorites smart album)");
318 Ok(())
319}
320
321fn migrate_v22_to_v23(conn: &Connection) -> SqliteResult<()> {
322 let tx = conn.unchecked_transaction()?;
323 for (col, def) in &[
324 ("media_type", "TEXT NOT NULL DEFAULT 'photo'"),
325 ("duration_ms", "INTEGER"),
326 ("video_codec", "TEXT"),
327 ("audio_codec", "TEXT"),
328 ("frame_rate", "REAL"),
329 ("bitrate", "INTEGER"),
330 ("has_audio", "BOOLEAN DEFAULT FALSE"),
331 ] {
332 let sql = format!("ALTER TABLE photos ADD COLUMN {} {}", col, def);
333 match tx.execute(&sql, []) {
334 Ok(_) => {}
335 Err(e) => {
336 let msg = e.to_string();
337 if !msg.contains("duplicate column") {
338 return Err(e);
339 }
340 }
341 }
342 }
343 tx.execute(
344 "CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)",
345 [],
346 )?;
347 tx.execute("INSERT INTO schema_version (version) VALUES (23)", [])?;
348 tx.commit()?;
349 tracing::info!("Migrated database to schema version 23 (video media metadata)");
350 Ok(())
351}
352
353fn migrate_v21_to_v22(conn: &Connection) -> SqliteResult<()> {
354 let tx = conn.unchecked_transaction()?;
355 tx.execute_batch(
356 r#"
357 CREATE TABLE IF NOT EXISTS photo_stacks (
358 id INTEGER PRIMARY KEY,
359 kind TEXT NOT NULL,
360 source_group_id INTEGER NOT NULL,
361 source_group_hash TEXT,
362 cover_photo_id INTEGER NOT NULL,
363 confidence REAL NOT NULL DEFAULT 1.0,
364 dismissed BOOLEAN DEFAULT FALSE,
365 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
366 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
367 UNIQUE(kind, source_group_id)
368 );
369
370 CREATE TABLE IF NOT EXISTS photo_stack_members (
371 id INTEGER PRIMARY KEY,
372 stack_id INTEGER NOT NULL,
373 photo_id INTEGER NOT NULL,
374 quality_score REAL NOT NULL DEFAULT 0,
375 score_reasons TEXT,
376 is_cover BOOLEAN DEFAULT FALSE,
377 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
378 FOREIGN KEY (stack_id) REFERENCES photo_stacks(id) ON DELETE CASCADE,
379 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
380 UNIQUE(stack_id, photo_id)
381 );
382
383 CREATE INDEX IF NOT EXISTS idx_photo_stacks_cover ON photo_stacks(cover_photo_id);
384 CREATE INDEX IF NOT EXISTS idx_photo_stacks_active ON photo_stacks(dismissed, kind);
385 CREATE INDEX IF NOT EXISTS idx_photo_stack_members_stack ON photo_stack_members(stack_id);
386 CREATE INDEX IF NOT EXISTS idx_photo_stack_members_photo ON photo_stack_members(photo_id);
387 CREATE INDEX IF NOT EXISTS idx_photo_stack_members_cover ON photo_stack_members(stack_id, is_cover);
388 "#,
389 )?;
390 tx.execute("INSERT INTO schema_version (version) VALUES (22)", [])?;
391 tx.commit()?;
392 tracing::info!("Migrated database to schema version 22 (timeline photo stacks)");
393 Ok(())
394}
395
396fn migrate_v18_to_v19(conn: &Connection) -> SqliteResult<()> {
397 let tx = conn.unchecked_transaction()?;
402 for (col, def) in &[
403 ("metadata_extracted", "BOOLEAN DEFAULT FALSE"),
404 ("thumbnailed", "BOOLEAN DEFAULT FALSE"),
405 ] {
406 let sql = format!("ALTER TABLE photos ADD COLUMN {} {}", col, def);
407 match tx.execute(&sql, []) {
408 Ok(_) => {}
409 Err(e) => {
410 let msg = e.to_string();
411 if !msg.contains("duplicate column") {
412 return Err(e);
413 }
414 }
415 }
416 }
417 tx.execute(
418 "UPDATE photos SET metadata_extracted = TRUE WHERE date_taken IS NOT NULL OR camera_make IS NOT NULL",
419 [],
420 )?;
421 tx.execute(
422 "UPDATE photos SET thumbnailed = TRUE WHERE thumbnail_path IS NOT NULL",
423 [],
424 )?;
425 tx.execute(
426 "CREATE INDEX IF NOT EXISTS idx_photos_metadata_extracted ON photos(metadata_extracted) WHERE metadata_extracted = FALSE",
427 [],
428 )?;
429 tx.execute(
430 "CREATE INDEX IF NOT EXISTS idx_photos_thumbnailed ON photos(thumbnailed) WHERE thumbnailed = FALSE",
431 [],
432 )?;
433 tx.execute("INSERT INTO schema_version (version) VALUES (19)", [])?;
434 tx.commit()?;
435 tracing::info!("Migrated database to schema version 19 (scanner pipeline stages)");
436 Ok(())
437}
438
439fn migrate_v19_to_v20(conn: &Connection) -> SqliteResult<()> {
440 let tx = conn.unchecked_transaction()?;
441 tx.execute_batch(
442 r#"
443 CREATE TABLE IF NOT EXISTS face_negatives (
444 face_id INTEGER NOT NULL,
445 not_cluster_id INTEGER NOT NULL,
446 created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
447 PRIMARY KEY (face_id, not_cluster_id),
448 FOREIGN KEY (face_id) REFERENCES faces(id) ON DELETE CASCADE,
449 FOREIGN KEY (not_cluster_id) REFERENCES face_clusters(id) ON DELETE CASCADE
450 );
451 CREATE INDEX IF NOT EXISTS idx_face_negatives_cluster ON face_negatives(not_cluster_id);
452 INSERT INTO schema_version (version) VALUES (20);
453 "#,
454 )?;
455 tx.commit()?;
456 tracing::info!("Migrated database to schema version 20 (face_negatives)");
457 Ok(())
458}
459
460fn migrate_v20_to_v21(conn: &Connection) -> SqliteResult<()> {
461 let tx = conn.unchecked_transaction()?;
462 tx.execute_batch(
463 r#"
464 CREATE TABLE IF NOT EXISTS face_processing_stats (
465 id INTEGER PRIMARY KEY CHECK (id = 1),
466 rejected_small INTEGER NOT NULL DEFAULT 0,
467 rejected_lowconf INTEGER NOT NULL DEFAULT 0,
468 rejected_blurry INTEGER NOT NULL DEFAULT 0,
469 rejected_yaw INTEGER NOT NULL DEFAULT 0,
470 completed_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
471 );
472 INSERT INTO schema_version (version) VALUES (21);
473 "#,
474 )?;
475 tx.commit()?;
476 tracing::info!("Migrated database to schema version 21 (face_processing_stats)");
477 Ok(())
478}
479
480fn migrate_v17_to_v18(conn: &Connection) -> SqliteResult<()> {
481 let tx = conn.unchecked_transaction()?;
485 match tx.execute("ALTER TABLE photos ADD COLUMN phash INTEGER", []) {
486 Ok(_) => {}
487 Err(e) => {
488 let msg = e.to_string();
489 if !msg.contains("duplicate column") {
490 return Err(e);
491 }
492 }
493 }
494 tx.execute(
495 "CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos(phash) WHERE phash IS NOT NULL",
496 [],
497 )?;
498 tx.execute("INSERT INTO schema_version (version) VALUES (18)", [])?;
499 tx.commit()?;
500 tracing::info!("Migrated database to schema version 18 (photos.phash)");
501 Ok(())
502}
503
504fn migrate_v16_to_v17(conn: &Connection) -> SqliteResult<()> {
505 let tx = conn.unchecked_transaction()?;
509 match tx.execute("ALTER TABLE photos ADD COLUMN brightness REAL", []) {
510 Ok(_) => {}
511 Err(e) => {
512 let msg = e.to_string();
513 if !msg.contains("duplicate column") {
514 return Err(e);
515 }
516 }
517 }
518 tx.execute("INSERT INTO schema_version (version) VALUES (17)", [])?;
519 tx.commit()?;
520 tracing::info!("Migrated database to schema version 17 (photos.brightness)");
521 Ok(())
522}
523
524fn migrate_v15_to_v16(conn: &Connection) -> SqliteResult<()> {
525 let tx = conn.unchecked_transaction()?;
529 tx.execute_batch(
530 r#"
531 UPDATE photos SET file_path = REPLACE(file_path, '\', '/');
532 UPDATE photos SET thumbnail_path = REPLACE(thumbnail_path, '\', '/')
533 WHERE thumbnail_path IS NOT NULL;
534 UPDATE trash SET original_path = REPLACE(original_path, '\', '/');
535
536 INSERT INTO schema_version (version) VALUES (16);
537 "#,
538 )?;
539 tx.commit()?;
540 tracing::info!("Migrated database to schema version 16 (forward-slash paths)");
541 Ok(())
542}
543
544fn migrate_v14_to_v15(conn: &Connection) -> SqliteResult<()> {
545 conn.execute_batch(
549 r#"
550 CREATE INDEX IF NOT EXISTS idx_photos_trashed_date
551 ON photos(is_trashed, date_taken DESC);
552
553 CREATE INDEX IF NOT EXISTS idx_photos_faces_processed_trashed
554 ON photos(faces_processed, is_trashed, date_taken DESC);
555
556 CREATE INDEX IF NOT EXISTS idx_faces_cluster_confidence
557 ON faces(cluster_id, confidence DESC, id);
558
559 CREATE INDEX IF NOT EXISTS idx_faces_photo_cluster
560 ON faces(photo_id, cluster_id);
561
562 INSERT INTO schema_version (version) VALUES (15);
563 "#,
564 )?;
565 tracing::info!("Migrated database to schema version 15 (composite indexes)");
566 Ok(())
567}
568
569fn migrate_v13_to_v14(conn: &Connection) -> SqliteResult<()> {
570 conn.execute_batch(
571 r#"
572 CREATE TABLE IF NOT EXISTS recent_searches (
573 id INTEGER PRIMARY KEY,
574 query TEXT NOT NULL,
575 last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
576 use_count INTEGER DEFAULT 1,
577 UNIQUE(query)
578 );
579
580 CREATE INDEX IF NOT EXISTS idx_recent_searches_used
581 ON recent_searches(last_used DESC);
582
583 INSERT INTO schema_version (version) VALUES (14);
584 "#,
585 )?;
586 tracing::info!("Migrated database to schema version 14 (recent searches)");
587 Ok(())
588}
589
590fn migrate_v12_to_v13(conn: &Connection) -> SqliteResult<()> {
591 conn.execute_batch(
592 r#"
593 CREATE TABLE IF NOT EXISTS album_suggestions (
594 id INTEGER PRIMARY KEY,
595 kind TEXT NOT NULL,
596 title TEXT NOT NULL,
597 photo_ids_json TEXT NOT NULL,
598 cover_photo_id INTEGER,
599 fingerprint TEXT NOT NULL,
600 status TEXT NOT NULL DEFAULT 'pending',
601 seen_count INTEGER DEFAULT 0,
602 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
603 FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL
604 );
605
606 CREATE INDEX IF NOT EXISTS idx_album_suggestions_status ON album_suggestions(status);
607 CREATE INDEX IF NOT EXISTS idx_album_suggestions_fingerprint ON album_suggestions(fingerprint);
608
609 INSERT INTO schema_version (version) VALUES (13);
610 "#,
611 )?;
612
613 tracing::info!("Migrated database to schema version 13 (album suggestions)");
614 Ok(())
615}
616
617fn migrate_v11_to_v12(conn: &Connection) -> SqliteResult<()> {
618 conn.execute_batch(
619 r#"
620 CREATE TABLE IF NOT EXISTS albums (
621 id INTEGER PRIMARY KEY,
622 name TEXT NOT NULL,
623 cover_photo_id INTEGER,
624 cover_auto_picked BOOLEAN DEFAULT TRUE,
625 photo_count INTEGER DEFAULT 0,
626 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
627 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
628 FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL
629 );
630
631 CREATE TABLE IF NOT EXISTS album_photos (
632 id INTEGER PRIMARY KEY,
633 album_id INTEGER NOT NULL,
634 photo_id INTEGER NOT NULL,
635 added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
636 FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE,
637 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
638 UNIQUE(album_id, photo_id)
639 );
640
641 CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id);
642 CREATE INDEX IF NOT EXISTS idx_album_photos_photo ON album_photos(photo_id);
643
644 INSERT INTO schema_version (version) VALUES (12);
645 "#,
646 )?;
647
648 tracing::info!("Migrated database to schema version 12 (albums)");
649 Ok(())
650}
651
652fn migrate_v10_to_v11(conn: &Connection) -> SqliteResult<()> {
653 conn.execute_batch(
654 r#"
655 CREATE TABLE IF NOT EXISTS memory_blocks (
656 id INTEGER PRIMARY KEY,
657 kind TEXT NOT NULL,
658 target_key TEXT NOT NULL,
659 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
660 UNIQUE(kind, target_key)
661 );
662
663 CREATE INDEX IF NOT EXISTS idx_memory_blocks_kind ON memory_blocks(kind);
664
665 INSERT INTO schema_version (version) VALUES (11);
666 "#,
667 )?;
668
669 tracing::info!("Migrated database to schema version 11 (memory blocks)");
670 Ok(())
671}
672
673fn migrate_v3_to_v4(conn: &Connection) -> SqliteResult<()> {
674 let tx = conn.unchecked_transaction()?;
678 let columns = [
679 ("lens_model", "TEXT"),
680 ("flash", "TEXT"),
681 ("gps_altitude", "REAL"),
682 ];
683
684 for (col, col_type) in &columns {
685 let sql = format!("ALTER TABLE photos ADD COLUMN {} {}", col, col_type);
686 match tx.execute(&sql, []) {
687 Ok(_) => {}
688 Err(e) => {
689 let msg = e.to_string();
690 if !msg.contains("duplicate column") {
691 return Err(e);
692 }
693 }
694 }
695 }
696
697 tx.execute("INSERT INTO schema_version (version) VALUES (4)", [])?;
698 tx.commit()?;
699 tracing::info!("Migrated database to schema version 4 (lens, flash, altitude)");
700 Ok(())
701}
702
703fn migrate_v5_to_v6(conn: &Connection) -> SqliteResult<()> {
704 let tx = conn.unchecked_transaction()?;
708
709 let columns = [
710 ("content_category", "TEXT DEFAULT 'photo'"),
711 ("ocr_text", "TEXT"),
712 ("ocr_processed", "BOOLEAN DEFAULT FALSE"),
713 ("ocr_confidence", "REAL"),
714 ];
715
716 for (col, col_type) in &columns {
717 let sql = format!("ALTER TABLE photos ADD COLUMN {} {}", col, col_type);
718 match tx.execute(&sql, []) {
719 Ok(_) => {}
720 Err(e) => {
721 let msg = e.to_string();
722 if !msg.contains("duplicate column") {
723 return Err(e);
724 }
725 }
726 }
727 }
728
729 tx.execute_batch(
730 r#"
731 CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
732 ocr_text,
733 content='photos',
734 content_rowid='id'
735 );
736
737 CREATE TRIGGER IF NOT EXISTS photos_fts_insert AFTER INSERT ON photos BEGIN
738 INSERT INTO photos_fts(rowid, ocr_text) VALUES (new.id, COALESCE(new.ocr_text, ''));
739 END;
740
741 CREATE TRIGGER IF NOT EXISTS photos_fts_update AFTER UPDATE OF ocr_text ON photos BEGIN
742 UPDATE photos_fts SET ocr_text = COALESCE(new.ocr_text, '') WHERE rowid = new.id;
743 END;
744
745 CREATE TRIGGER IF NOT EXISTS photos_fts_delete AFTER DELETE ON photos BEGIN
746 DELETE FROM photos_fts WHERE rowid = old.id;
747 END;
748
749 CREATE INDEX IF NOT EXISTS idx_photos_content_category ON photos(content_category);
750 CREATE INDEX IF NOT EXISTS idx_photos_ocr_processed ON photos(ocr_processed);
751
752 INSERT INTO schema_version (version) VALUES (6);
753 "#,
754 )?;
755 tx.commit()?;
756
757 tracing::info!("Migrated database to schema version 6 (documents + OCR fields)");
758 Ok(())
759}
760
761fn migrate_v6_to_v7(conn: &Connection) -> SqliteResult<()> {
762 match conn.execute(
763 "ALTER TABLE face_clusters ADD COLUMN photo_count INTEGER DEFAULT 0",
764 [],
765 ) {
766 Ok(_) => {}
767 Err(e) => {
768 let msg = e.to_string();
769 if !msg.contains("duplicate column") {
770 return Err(e);
771 }
772 }
773 }
774
775 conn.execute_batch(
776 r#"
777 UPDATE face_clusters
778 SET
779 face_count = (SELECT COUNT(*) FROM faces WHERE cluster_id = face_clusters.id),
780 photo_count = (
781 SELECT COUNT(DISTINCT photo_id)
782 FROM (
783 SELECT photo_id FROM faces WHERE cluster_id = face_clusters.id
784 UNION
785 SELECT photo_id FROM photo_inferred_identities WHERE cluster_id = face_clusters.id
786 )
787 ),
788 representative_face_id = (
789 SELECT id
790 FROM faces
791 WHERE cluster_id = face_clusters.id
792 ORDER BY confidence DESC
793 LIMIT 1
794 ),
795 updated_at = CURRENT_TIMESTAMP;
796
797 INSERT INTO schema_version (version) VALUES (7);
798 "#,
799 )?;
800
801 tracing::info!("Migrated database to schema version 7 (face cluster photo counts)");
802 Ok(())
803}
804
805fn migrate_v7_to_v8(conn: &Connection) -> SqliteResult<()> {
806 match conn.execute(
807 "ALTER TABLE photo_inferred_identities ADD COLUMN is_inferred BOOLEAN DEFAULT TRUE",
808 [],
809 ) {
810 Ok(_) => {}
811 Err(e) => {
812 let msg = e.to_string();
813 if !msg.contains("duplicate column") {
814 return Err(e);
815 }
816 }
817 }
818
819 conn.execute_batch(
820 r#"
821 UPDATE photo_inferred_identities
822 SET is_inferred = TRUE
823 WHERE is_inferred IS NULL;
824
825 CREATE INDEX IF NOT EXISTS idx_inferred_is_inferred ON photo_inferred_identities(is_inferred);
826
827 INSERT INTO schema_version (version) VALUES (8);
828 "#,
829 )?;
830
831 tracing::info!("Migrated database to schema version 8 (inferred identity flag)");
832 Ok(())
833}
834
835fn migrate_v9_to_v10(conn: &Connection) -> SqliteResult<()> {
836 let add_col = |sql: &str| -> SqliteResult<()> {
837 match conn.execute(sql, []) {
838 Ok(_) => Ok(()),
839 Err(e) => {
840 let msg = e.to_string();
841 if msg.contains("duplicate column") {
842 Ok(())
843 } else {
844 Err(e)
845 }
846 }
847 }
848 };
849
850 add_col("ALTER TABLE faces ADD COLUMN user_confirmed INTEGER DEFAULT 0")?;
851 add_col("ALTER TABLE face_clusters ADD COLUMN is_user_named INTEGER DEFAULT 0")?;
852 add_col("ALTER TABLE person_gallery_embeddings ADD COLUMN source TEXT DEFAULT 'auto'")?;
853
854 conn.execute_batch(
855 r#"
856 CREATE TABLE IF NOT EXISTS cluster_cannot_merge (
857 id INTEGER PRIMARY KEY,
858 cluster_a_id INTEGER NOT NULL,
859 cluster_b_id INTEGER NOT NULL,
860 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
861 FOREIGN KEY (cluster_a_id) REFERENCES face_clusters(id) ON DELETE CASCADE,
862 FOREIGN KEY (cluster_b_id) REFERENCES face_clusters(id) ON DELETE CASCADE,
863 UNIQUE(cluster_a_id, cluster_b_id)
864 );
865
866 CREATE INDEX IF NOT EXISTS idx_cannot_merge_a ON cluster_cannot_merge(cluster_a_id);
867 CREATE INDEX IF NOT EXISTS idx_cannot_merge_b ON cluster_cannot_merge(cluster_b_id);
868
869 CREATE TABLE IF NOT EXISTS face_review_queue (
870 id INTEGER PRIMARY KEY,
871 face_id INTEGER NOT NULL,
872 candidate_cluster_id INTEGER NOT NULL,
873 score REAL NOT NULL,
874 ambiguity REAL,
875 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
876 resolved_at DATETIME,
877 resolved_as TEXT,
878 FOREIGN KEY (face_id) REFERENCES faces(id) ON DELETE CASCADE,
879 FOREIGN KEY (candidate_cluster_id) REFERENCES face_clusters(id) ON DELETE CASCADE,
880 UNIQUE(face_id, candidate_cluster_id)
881 );
882
883 CREATE INDEX IF NOT EXISTS idx_review_queue_face ON face_review_queue(face_id);
884 CREATE INDEX IF NOT EXISTS idx_review_queue_cluster ON face_review_queue(candidate_cluster_id);
885 CREATE INDEX IF NOT EXISTS idx_review_queue_unresolved
886 ON face_review_queue(resolved_at) WHERE resolved_at IS NULL;
887
888 CREATE INDEX IF NOT EXISTS idx_gallery_source ON person_gallery_embeddings(source);
889
890 INSERT INTO schema_version (version) VALUES (10);
891 "#,
892 )?;
893
894 tracing::info!("Migrated database to schema version 10 (face feedback tables)");
895 Ok(())
896}
897
898fn migrate_v8_to_v9(conn: &Connection) -> SqliteResult<()> {
899 conn.execute_batch(
900 r#"
901 CREATE TABLE IF NOT EXISTS person_gallery_embeddings (
902 id INTEGER PRIMARY KEY,
903 cluster_id INTEGER NOT NULL,
904 face_id INTEGER NOT NULL,
905 embedding BLOB NOT NULL,
906 pose_label TEXT,
907 quality_score REAL,
908 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
909
910 FOREIGN KEY (cluster_id) REFERENCES face_clusters(id) ON DELETE CASCADE,
911 FOREIGN KEY (face_id) REFERENCES faces(id) ON DELETE CASCADE,
912 UNIQUE(cluster_id, face_id)
913 );
914
915 CREATE INDEX IF NOT EXISTS idx_gallery_cluster ON person_gallery_embeddings(cluster_id);
916 CREATE INDEX IF NOT EXISTS idx_gallery_face ON person_gallery_embeddings(face_id);
917
918 INSERT INTO schema_version (version) VALUES (9);
919 "#,
920 )?;
921
922 tracing::info!("Migrated database to schema version 9 (person gallery embeddings)");
923 Ok(())
924}
925
926fn migrate_v4_to_v5(conn: &Connection) -> SqliteResult<()> {
927 conn.execute_batch(
928 r#"
929 CREATE TABLE IF NOT EXISTS photo_inferred_identities (
930 id INTEGER PRIMARY KEY,
931 photo_id INTEGER NOT NULL,
932 cluster_id INTEGER NOT NULL,
933 source_photo_id INTEGER NOT NULL,
934 confidence REAL NOT NULL,
935 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
936
937 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
938 FOREIGN KEY (cluster_id) REFERENCES face_clusters(id) ON DELETE CASCADE,
939 FOREIGN KEY (source_photo_id) REFERENCES photos(id) ON DELETE CASCADE,
940 UNIQUE(photo_id, cluster_id)
941 );
942
943 CREATE INDEX IF NOT EXISTS idx_inferred_photo ON photo_inferred_identities(photo_id);
944 CREATE INDEX IF NOT EXISTS idx_inferred_cluster ON photo_inferred_identities(cluster_id);
945
946 INSERT INTO schema_version (version) VALUES (5);
947 "#,
948 )?;
949
950 tracing::info!("Migrated database to schema version 5 (inferred identities)");
951 Ok(())
952}
953
954fn migrate_v2_to_v3(conn: &Connection) -> SqliteResult<()> {
955 let tx = conn.unchecked_transaction()?;
959 let columns = [
960 ("iso", "INTEGER"),
961 ("aperture", "TEXT"),
962 ("shutter_speed", "TEXT"),
963 ("focal_length", "TEXT"),
964 ("lens_model", "TEXT"),
965 ("flash", "TEXT"),
966 ("gps_altitude", "REAL"),
967 ];
968
969 for (col, col_type) in &columns {
970 let sql = format!("ALTER TABLE photos ADD COLUMN {} {}", col, col_type);
971 match tx.execute(&sql, []) {
972 Ok(_) => {}
973 Err(e) => {
974 let msg = e.to_string();
976 if !msg.contains("duplicate column") {
977 return Err(e);
978 }
979 }
980 }
981 }
982
983 tx.execute("INSERT INTO schema_version (version) VALUES (3)", [])?;
984 tx.commit()?;
985 tracing::info!("Migrated database to schema version 3 (EXIF shooting params)");
986 Ok(())
987}
988
989fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
990 conn.execute_batch(
991 r#"
992 CREATE TABLE IF NOT EXISTS trash (
993 id INTEGER PRIMARY KEY,
994 photo_id INTEGER NOT NULL UNIQUE,
995 original_path TEXT NOT NULL,
996 trashed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
997 FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE
998 );
999
1000 CREATE INDEX IF NOT EXISTS idx_trash_trashed_at ON trash(trashed_at);
1001 CREATE INDEX IF NOT EXISTS idx_face_clusters_name ON face_clusters(name);
1002
1003 INSERT INTO schema_version (version) VALUES (2);
1004 "#,
1005 )?;
1006
1007 Ok(())
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use rusqlite::Connection;
1014
1015 #[test]
1016 fn run_migrations_reports_newer_schema_version() {
1017 let conn = Connection::open_in_memory().unwrap();
1018 conn.execute_batch(
1019 r#"
1020 CREATE TABLE schema_version (
1021 version INTEGER PRIMARY KEY,
1022 applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
1023 );
1024 INSERT INTO schema_version (version) VALUES (999);
1025 "#,
1026 )
1027 .unwrap();
1028
1029 let err = run_migrations(&conn).unwrap_err();
1030 let schema = err
1031 .downcast_ref::<SchemaTooNewError>()
1032 .expect("newer schema should produce SchemaTooNewError");
1033 assert_eq!(schema.db_version, 999);
1034 assert_eq!(schema.max_supported, MAX_KNOWN_SCHEMA_VERSION);
1035 }
1036
1037 #[test]
1038 fn migrate_v27_to_v28_adds_takeout_ledger() {
1039 let conn = Connection::open_in_memory().unwrap();
1040 conn.execute_batch(
1041 r#"
1042 CREATE TABLE schema_version (
1043 version INTEGER PRIMARY KEY,
1044 applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
1045 );
1046 INSERT INTO schema_version (version) VALUES (27);
1047 "#,
1048 )
1049 .unwrap();
1050
1051 migrate_v27_to_v28(&conn).unwrap();
1052 assert_eq!(get_schema_version(&conn).unwrap(), 28);
1053 let table_count: i64 = conn
1054 .query_row(
1055 "SELECT COUNT(*) FROM sqlite_master
1056 WHERE type = 'table'
1057 AND name IN ('google_takeout_items', 'google_takeout_albums')",
1058 [],
1059 |row| row.get(0),
1060 )
1061 .unwrap();
1062 assert_eq!(table_count, 2);
1063 }
1064
1065 #[test]
1066 fn run_migrations_adds_timeline_order_index_without_version_bump() {
1067 let conn = Connection::open_in_memory().unwrap();
1068 conn.execute_batch(
1069 r#"
1070 CREATE TABLE schema_version (
1071 version INTEGER PRIMARY KEY,
1072 applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
1073 );
1074 INSERT INTO schema_version (version) VALUES (28);
1075 CREATE TABLE photos (
1076 id INTEGER PRIMARY KEY,
1077 date_taken DATETIME,
1078 gps_latitude REAL,
1079 gps_longitude REAL,
1080 is_favorite BOOLEAN DEFAULT FALSE,
1081 is_trashed BOOLEAN DEFAULT FALSE
1082 );
1083 CREATE TABLE faces (
1084 id INTEGER PRIMARY KEY,
1085 photo_id INTEGER NOT NULL,
1086 cluster_id INTEGER,
1087 user_confirmed INTEGER DEFAULT 0
1088 );
1089 "#,
1090 )
1091 .unwrap();
1092
1093 run_migrations(&conn).unwrap();
1094
1095 let version = get_schema_version(&conn).unwrap();
1096 assert_eq!(version, 28);
1097 let plan = conn
1098 .prepare(
1099 "EXPLAIN QUERY PLAN
1100 SELECT id FROM photos
1101 WHERE is_trashed = 0
1102 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1103 LIMIT 50",
1104 )
1105 .unwrap()
1106 .query_map([], |row| row.get::<_, String>(3))
1107 .unwrap()
1108 .collect::<rusqlite::Result<Vec<_>>>()
1109 .unwrap()
1110 .join("\n");
1111 assert!(plan.contains("idx_photos_timeline_order"), "{plan}");
1112 assert!(!plan.contains("USE TEMP B-TREE"), "{plan}");
1113 let plan = conn
1114 .prepare(
1115 "EXPLAIN QUERY PLAN
1116 SELECT id FROM faces
1117 WHERE cluster_id = 10 AND user_confirmed = 0
1118 ORDER BY id ASC
1119 LIMIT 50",
1120 )
1121 .unwrap()
1122 .query_map([], |row| row.get::<_, String>(3))
1123 .unwrap()
1124 .collect::<rusqlite::Result<Vec<_>>>()
1125 .unwrap()
1126 .join("\n");
1127 assert!(plan.contains("idx_faces_review_pending"), "{plan}");
1128 assert!(!plan.contains("USE TEMP B-TREE"), "{plan}");
1129 let plan = conn
1130 .prepare(
1131 "EXPLAIN QUERY PLAN
1132 SELECT id FROM photos
1133 WHERE is_favorite = TRUE AND is_trashed = 0
1134 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1135 LIMIT 50",
1136 )
1137 .unwrap()
1138 .query_map([], |row| row.get::<_, String>(3))
1139 .unwrap()
1140 .collect::<rusqlite::Result<Vec<_>>>()
1141 .unwrap()
1142 .join("\n");
1143 assert!(plan.contains("idx_photos_favorite_order"), "{plan}");
1144 assert!(!plan.contains("USE TEMP B-TREE"), "{plan}");
1145 let plan = conn
1146 .prepare(
1147 "EXPLAIN QUERY PLAN
1148 SELECT id FROM photos
1149 WHERE is_trashed = 0
1150 AND gps_latitude IS NOT NULL
1151 AND gps_longitude IS NOT NULL
1152 AND gps_latitude >= 10.0 AND gps_latitude <= 20.0
1153 AND gps_longitude >= 70.0 AND gps_longitude <= 80.0
1154 LIMIT 50",
1155 )
1156 .unwrap()
1157 .query_map([], |row| row.get::<_, String>(3))
1158 .unwrap()
1159 .collect::<rusqlite::Result<Vec<_>>>()
1160 .unwrap()
1161 .join("\n");
1162 assert!(plan.contains("idx_photos_gps_bounds"), "{plan}");
1163 }
1164
1165 #[test]
1166 fn test_migrate_v18_to_v19() -> rusqlite::Result<()> {
1167 let conn = Connection::open_in_memory().unwrap();
1168 conn.execute_batch(
1169 r#"
1170 CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP);
1171 INSERT INTO schema_version (version) VALUES (1);
1172
1173 CREATE TABLE IF NOT EXISTS photos (
1174 id INTEGER PRIMARY KEY,
1175 file_path TEXT NOT NULL,
1176 file_name TEXT NOT NULL,
1177 file_hash TEXT NOT NULL,
1178 file_size INTEGER NOT NULL,
1179 file_mtime INTEGER,
1180 date_taken DATETIME,
1181 date_taken_source TEXT,
1182 gps_latitude REAL,
1183 gps_longitude REAL,
1184 location_city TEXT,
1185 location_country TEXT,
1186 camera_make TEXT,
1187 camera_model TEXT,
1188 iso INTEGER,
1189 aperture TEXT,
1190 shutter_speed TEXT,
1191 focal_length TEXT,
1192 lens_model TEXT,
1193 flash TEXT,
1194 gps_altitude REAL,
1195 width INTEGER,
1196 height INTEGER,
1197 orientation INTEGER DEFAULT 1,
1198 thumbnail_path TEXT,
1199 faces_processed BOOLEAN DEFAULT FALSE,
1200 content_category TEXT DEFAULT 'photo',
1201 ocr_text TEXT,
1202 ocr_processed BOOLEAN DEFAULT FALSE,
1203 ocr_confidence REAL,
1204 brightness REAL,
1205 phash INTEGER,
1206 is_trashed BOOLEAN DEFAULT FALSE,
1207 trashed_at DATETIME,
1208 indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1209 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1210 UNIQUE(file_path)
1211 );
1212 "#,
1213 ).unwrap();
1214
1215 conn.execute(
1217 "INSERT INTO photos (file_path, file_name, file_hash, file_size, date_taken, camera_make, thumbnail_path, faces_processed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)",
1218 rusqlite::params!["/photos/img1.jpg", "img1.jpg", "abc123", 1000000, "2024-01-01T00:00:00Z", "Canon", "thumb/img1.jpg"],
1219 ).unwrap();
1220
1221 conn.execute(
1223 "INSERT INTO photos (file_path, file_name, file_hash, file_size, faces_processed) VALUES (?1, ?2, ?3, ?4, 0)",
1224 rusqlite::params!["/photos/img2.png", "img2.png", "def456", 500000],
1225 ).unwrap();
1226
1227 conn.execute("INSERT INTO schema_version (version) VALUES (18)", [])
1229 .unwrap();
1230
1231 migrate_v18_to_v19(&conn).unwrap();
1233
1234 let columns: Vec<String> = conn
1236 .prepare("PRAGMA table_info(photos)")?
1237 .query_map([], |row| row.get::<_, String>(1))?
1238 .collect::<rusqlite::Result<Vec<_>>>()?;
1239 assert!(columns.iter().any(|c| c == "metadata_extracted"));
1240 assert!(columns.iter().any(|c| c == "thumbnailed"));
1241
1242 let meta_flag: bool = conn
1244 .query_row(
1245 "SELECT metadata_extracted FROM photos WHERE file_path = '/photos/img1.jpg'",
1246 [],
1247 |row| row.get(0),
1248 )
1249 .unwrap();
1250 assert!(meta_flag);
1251
1252 let meta_flag2: bool = conn
1254 .query_row(
1255 "SELECT metadata_extracted FROM photos WHERE file_path = '/photos/img2.png'",
1256 [],
1257 |row| row.get(0),
1258 )
1259 .unwrap();
1260 assert!(!meta_flag2);
1261
1262 let thumb_flag: bool = conn
1264 .query_row(
1265 "SELECT thumbnailed FROM photos WHERE file_path = '/photos/img1.jpg'",
1266 [],
1267 |row| row.get(0),
1268 )
1269 .unwrap();
1270 assert!(thumb_flag);
1271
1272 let thumb_flag2: bool = conn
1274 .query_row(
1275 "SELECT thumbnailed FROM photos WHERE file_path = '/photos/img2.png'",
1276 [],
1277 |row| row.get(0),
1278 )
1279 .unwrap();
1280 assert!(!thumb_flag2);
1281
1282 let idx_count: i64 = conn.query_row(
1284 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_photos_metadata_extracted', 'idx_photos_thumbnailed')",
1285 [],
1286 |row| row.get(0),
1287 ).unwrap();
1288 assert_eq!(idx_count, 2);
1289 Ok(())
1290 }
1291
1292 #[test]
1293 fn test_migrate_v24_to_v25() {
1294 let conn = Connection::open_in_memory().unwrap();
1295 conn.execute_batch(
1296 r#"
1297 CREATE TABLE schema_version (
1298 version INTEGER PRIMARY KEY,
1299 applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
1300 );
1301 INSERT INTO schema_version (version) VALUES (24);
1302 "#,
1303 )
1304 .unwrap();
1305
1306 migrate_v24_to_v25(&conn).unwrap();
1307
1308 let table_count: i64 = conn
1309 .query_row(
1310 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='excluded_folders'",
1311 [],
1312 |row| row.get(0),
1313 )
1314 .unwrap();
1315 assert_eq!(table_count, 1);
1316
1317 let version = get_schema_version(&conn).unwrap();
1318 assert_eq!(version, 25);
1319 }
1320}