data/ThreadProjectionSchema.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | #include <QSqlDatabase> | ||
| 3 | #include <QSqlError> | ||
| 4 | #include <QSqlQuery> | ||
| 5 | #include <QStringList> | ||
| 6 | #include <QVariant> | ||
| 7 | |||
| 8 | // Versioned derived schema, like the existing predictor revisions. It is | ||
| 9 | // additive to the mail schema and never rewrites or deletes mail payloads. | ||
| 10 | // Install revision triggers before any writer can modify the first profile. | ||
| 11 | // | ||
| 12 | // P90.3 revision classes: structural header changes (identity, references, | ||
| 13 | // subject, participants, date, …) enqueue thread_dirty and rebuild connected | ||
| 14 | // components. Presentation-only changes never rebuild topology: | ||
| 15 | // - flags-only updates maintain unread_descendants incrementally on the | ||
| 16 | // ancestor chain (the only projection field derived from flags); | ||
| 17 | // - label changes only bump the folder revision (labels are joined live | ||
| 18 | // at page-read time and never appear in projection columns). | ||
| 19 | 1672 | inline bool ensureThreadProjectionSchema(QSqlDatabase &db, QString *error) { | |
| 20 |
1/1✓ Branch 2 → 3 taken 1672 times.
|
1672 | QSqlQuery query(db); |
| 21 |
2/3✓ Branch 6 → 7 taken 1672 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 16 taken 1672 times.
|
1672 | if (!query.exec(QStringLiteral("SELECT count(*) FROM sqlite_master WHERE name IN " |
| 22 | "('thread_revisions','thread_dirty','thread_projection','thread_terms','thread_fallback_candidates','thread_projection_meta'," | ||
| 23 | "'thread_change_ai','thread_change_ad','thread_change_au','thread_flags_au','thread_labels_ai','thread_labels_ad'," | ||
| 24 | "'thread_projection_order','thread_projection_root','thread_terms_uid','thread_fallback_candidates_uid')"))) { | ||
| 25 | ✗ | *error = query.lastError().text(); return false; | |
| 26 | } | ||
| 27 |
7/11✓ Branch 16 → 17 taken 1672 times.
✓ Branch 17 → 18 taken 1672 times.
✗ Branch 17 → 22 not taken.
✓ Branch 18 → 19 taken 1672 times.
✓ Branch 19 → 20 taken 1672 times.
✓ Branch 20 → 21 taken 135 times.
✓ Branch 20 → 22 taken 1537 times.
✓ Branch 23 → 24 taken 1672 times.
✗ Branch 23 → 25 not taken.
✗ Branch 351 → 352 not taken.
✗ Branch 351 → 353 not taken.
|
1672 | const bool complete=query.next() && query.value(0).toInt()==16; |
| 28 |
1/1✓ Branch 25 → 26 taken 1672 times.
|
1672 | query.finish(); |
| 29 | const QList<QPair<QString, QStringList>> shapes{ | ||
| 30 | {"thread_revisions", {"folder_id", "revision"}}, | ||
| 31 | {"thread_dirty", {"folder_id", "uid", "revision"}}, | ||
| 32 | {"thread_projection_meta", {"folder_id", "version", "revision", "uidvalidity"}}, | ||
| 33 | {"thread_projection", {"folder_id", "uid", "parent_uid", "root_uid", "undated", "newest_order", "path", "depth", "descendants", "unread_descendants", "subject_fold", "sender_fold"}}, | ||
| 34 | {"thread_terms", {"folder_id", "term", "uid"}}, | ||
| 35 | {"thread_fallback_candidates", {"folder_id", "subject", "participant", "reply", "date", "uid"}} | ||
| 36 |
14/28✓ Branch 76 → 77 taken 10032 times.
✓ Branch 76 → 78 taken 1672 times.
✓ Branch 80 → 81 taken 10032 times.
✓ Branch 80 → 82 taken 1672 times.
✓ Branch 85 → 86 taken 5016 times.
✓ Branch 85 → 87 taken 1672 times.
✓ Branch 90 → 91 taken 20064 times.
✓ Branch 90 → 92 taken 1672 times.
✓ Branch 95 → 96 taken 6688 times.
✓ Branch 95 → 97 taken 1672 times.
✓ Branch 100 → 101 taken 5016 times.
✓ Branch 100 → 102 taken 1672 times.
✓ Branch 105 → 106 taken 3344 times.
✓ Branch 105 → 107 taken 1672 times.
✗ Branch 356 → 357 not taken.
✗ Branch 356 → 358 not taken.
✗ Branch 362 → 363 not taken.
✗ Branch 362 → 364 not taken.
✗ Branch 378 → 379 not taken.
✗ Branch 378 → 380 not taken.
✗ Branch 394 → 395 not taken.
✗ Branch 394 → 396 not taken.
✗ Branch 410 → 411 not taken.
✗ Branch 410 → 412 not taken.
✗ Branch 426 → 427 not taken.
✗ Branch 426 → 428 not taken.
✗ Branch 442 → 443 not taken.
✗ Branch 442 → 444 not taken.
|
61864 | }; |
| 37 | 1672 | bool incompatible=false; | |
| 38 |
2/2✓ Branch 151 → 110 taken 10032 times.
✓ Branch 151 → 152 taken 1672 times.
|
11704 | for (const auto &[table, expected] : shapes) { |
| 39 |
4/5✓ Branch 118 → 119 taken 10032 times.
✓ Branch 119 → 120 taken 10032 times.
✓ Branch 120 → 121 taken 10032 times.
✗ Branch 125 → 126 not taken.
✓ Branch 125 → 132 taken 10032 times.
|
20064 | if (!query.exec(QStringLiteral("PRAGMA table_info(")+table+QLatin1Char(')'))) { |
| 40 | ✗ | *error=query.lastError().text(); return false; | |
| 41 | } | ||
| 42 | 10032 | QStringList columns; | |
| 43 |
6/6✓ Branch 133 → 134 taken 4056 times.
✓ Branch 134 → 135 taken 4056 times.
✓ Branch 135 → 136 taken 4056 times.
✓ Branch 139 → 140 taken 14088 times.
✓ Branch 140 → 133 taken 4056 times.
✓ Branch 140 → 141 taken 10032 times.
|
14088 | while (query.next()) columns.append(query.value(1).toString()); |
| 44 |
5/5✓ Branch 142 → 143 taken 811 times.
✓ Branch 142 → 146 taken 9221 times.
✓ Branch 143 → 144 taken 811 times.
✓ Branch 144 → 145 taken 1 time.
✓ Branch 144 → 146 taken 810 times.
|
10032 | incompatible |= !columns.isEmpty() && columns!=expected; |
| 45 | 10032 | } | |
| 46 |
1/1✓ Branch 152 → 153 taken 1672 times.
|
1672 | query.finish(); |
| 47 |
4/4✓ Branch 153 → 154 taken 135 times.
✓ Branch 153 → 156 taken 1537 times.
✓ Branch 154 → 155 taken 134 times.
✓ Branch 154 → 156 taken 1 time.
|
1672 | if (complete && !incompatible) return true; |
| 48 |
2/5✓ Branch 156 → 157 taken 1538 times.
✗ Branch 157 → 158 not taken.
✓ Branch 157 → 164 taken 1538 times.
✗ Branch 158 → 159 not taken.
✗ Branch 159 → 160 not taken.
|
1538 | if (!db.transaction()) { *error = db.lastError().text(); return false; } |
| 49 | // A damaged/older derived extension is discarded atomically. Mail headers, | ||
| 50 | // bodies, journal and UIDVALIDITY are never part of this repair. | ||
| 51 |
2/2✓ Branch 164 → 165 taken 1 time.
✓ Branch 164 → 212 taken 1537 times.
|
1538 | if (incompatible) { |
| 52 |
2/2✓ Branch 184 → 167 taken 6 times.
✓ Branch 184 → 185 taken 1 time.
|
7 | for (const auto *trigger : {"thread_change_ai", "thread_change_ad", "thread_change_au", "thread_flags_au", "thread_labels_ai", "thread_labels_ad"}) { |
| 53 |
3/4✓ Branch 170 → 171 taken 6 times.
✓ Branch 171 → 172 taken 6 times.
✗ Branch 175 → 176 not taken.
✓ Branch 175 → 183 taken 6 times.
|
6 | if (!query.exec(QStringLiteral("DROP TRIGGER IF EXISTS ")+trigger)) { |
| 54 | ✗ | *error=query.lastError().text(); db.rollback(); return false; | |
| 55 | } | ||
| 56 | } | ||
| 57 |
2/2✓ Branch 209 → 188 taken 6 times.
✓ Branch 209 → 210 taken 1 time.
|
7 | for (const auto &[table, expected] : shapes) { |
| 58 | Q_UNUSED(expected); | ||
| 59 |
3/4✓ Branch 194 → 195 taken 6 times.
✓ Branch 195 → 196 taken 6 times.
✗ Branch 199 → 200 not taken.
✓ Branch 199 → 207 taken 6 times.
|
6 | if (!query.exec(QStringLiteral("DROP TABLE IF EXISTS ")+table)) { |
| 60 | ✗ | *error=query.lastError().text(); db.rollback(); return false; | |
| 61 | } | ||
| 62 | } | ||
| 63 | } else { | ||
| 64 | // Upgrade path (P90.3): triggers are always recreated so an existing | ||
| 65 | // profile adopts the revised WHEN clauses atomically. Tables stay. | ||
| 66 |
2/2✓ Branch 231 → 214 taken 9222 times.
✓ Branch 231 → 232 taken 1537 times.
|
10759 | for (const auto *trigger : {"thread_change_ai", "thread_change_ad", "thread_change_au", "thread_flags_au", "thread_labels_ai", "thread_labels_ad"}) { |
| 67 |
3/4✓ Branch 217 → 218 taken 9222 times.
✓ Branch 218 → 219 taken 9222 times.
✗ Branch 222 → 223 not taken.
✓ Branch 222 → 230 taken 9222 times.
|
9222 | if (!query.exec(QStringLiteral("DROP TRIGGER IF EXISTS ")+trigger)) { |
| 68 | ✗ | *error=query.lastError().text(); db.rollback(); return false; | |
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | const QStringList statements{ | ||
| 73 | ✗ | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_revisions (folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE, revision INTEGER NOT NULL DEFAULT 0)"), | |
| 74 | 1538 | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_dirty (folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, uid INTEGER NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(folder_id,uid)) WITHOUT ROWID"), | |
| 75 | 1538 | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_projection_meta (folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE, version INTEGER NOT NULL, revision INTEGER NOT NULL, uidvalidity INTEGER NOT NULL)"), | |
| 76 | 1538 | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_projection (folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, uid INTEGER NOT NULL, parent_uid INTEGER NOT NULL, root_uid INTEGER NOT NULL, undated INTEGER NOT NULL, newest_order INTEGER NOT NULL, path BLOB NOT NULL, depth INTEGER NOT NULL, descendants INTEGER NOT NULL, unread_descendants INTEGER NOT NULL, subject_fold TEXT NOT NULL, sender_fold TEXT NOT NULL, PRIMARY KEY(folder_id,uid)) WITHOUT ROWID"), | |
| 77 | 1538 | QStringLiteral("CREATE INDEX IF NOT EXISTS thread_projection_order ON thread_projection(folder_id,undated,newest_order,root_uid,path)"), | |
| 78 | 1538 | QStringLiteral("CREATE INDEX IF NOT EXISTS thread_projection_root ON thread_projection(folder_id,root_uid)"), | |
| 79 | 1538 | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_terms (folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, term TEXT NOT NULL, uid INTEGER NOT NULL, PRIMARY KEY(folder_id,term,uid)) WITHOUT ROWID"), | |
| 80 | 1538 | QStringLiteral("CREATE INDEX IF NOT EXISTS thread_terms_uid ON thread_terms(folder_id,uid)"), | |
| 81 | 1538 | QStringLiteral("CREATE TABLE IF NOT EXISTS thread_fallback_candidates (folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, subject TEXT NOT NULL, participant TEXT NOT NULL, reply INTEGER NOT NULL, date INTEGER NOT NULL, uid INTEGER NOT NULL, PRIMARY KEY(folder_id,subject,participant,reply,date,uid)) WITHOUT ROWID"), | |
| 82 | 1538 | QStringLiteral("CREATE INDEX IF NOT EXISTS thread_fallback_candidates_uid ON thread_fallback_candidates(folder_id,uid)"), | |
| 83 | 1538 | QStringLiteral("INSERT OR IGNORE INTO thread_revisions(folder_id) SELECT id FROM folders"), | |
| 84 | // If a partial extension was repaired, all existing projections must be | ||
| 85 | // rebuilt. No stale membership can masquerade as a valid revision. | ||
| 86 | 1538 | QStringLiteral("DELETE FROM thread_projection_meta"), | |
| 87 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_change_ai AFTER INSERT ON headers BEGIN INSERT INTO thread_revisions(folder_id) VALUES(new.folder_id) ON CONFLICT(folder_id) DO NOTHING; UPDATE thread_revisions SET revision=revision+1 WHERE folder_id=new.folder_id; INSERT INTO thread_dirty SELECT new.folder_id,new.uid,revision FROM thread_revisions WHERE folder_id=new.folder_id ON CONFLICT(folder_id,uid) DO UPDATE SET revision=excluded.revision; END"), | |
| 88 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_change_ad AFTER DELETE ON headers BEGIN UPDATE thread_revisions SET revision=revision+1 WHERE folder_id=old.folder_id; INSERT INTO thread_dirty SELECT old.folder_id,old.uid,revision FROM thread_revisions WHERE folder_id=old.folder_id AND EXISTS(SELECT 1 FROM folders WHERE id=old.folder_id) ON CONFLICT(folder_id,uid) DO UPDATE SET revision=excluded.revision; END"), | |
| 89 | // P90.3: flags moved out of the structural WHEN. A flags-only update is | ||
| 90 | // handled by thread_flags_au without enqueueing a topology rebuild. | ||
| 91 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_change_au AFTER UPDATE ON headers WHEN old.folder_id IS NOT new.folder_id OR old.uid IS NOT new.uid OR old.subject IS NOT new.subject OR old.from_addr IS NOT new.from_addr OR old.to_addr IS NOT new.to_addr OR old.date IS NOT new.date OR old.size IS NOT new.size OR old.has_attachments IS NOT new.has_attachments OR old.message_id IS NOT new.message_id OR old.in_reply_to IS NOT new.in_reply_to OR old.ref_ids IS NOT new.ref_ids OR old.is_spam IS NOT new.is_spam BEGIN INSERT INTO thread_revisions(folder_id) VALUES(new.folder_id) ON CONFLICT(folder_id) DO NOTHING; UPDATE thread_revisions SET revision=revision+1 WHERE folder_id IN(old.folder_id,new.folder_id); INSERT INTO thread_dirty SELECT old.folder_id,old.uid,revision FROM thread_revisions WHERE folder_id=old.folder_id ON CONFLICT(folder_id,uid) DO UPDATE SET revision=excluded.revision; INSERT INTO thread_dirty SELECT new.folder_id,new.uid,revision FROM thread_revisions WHERE folder_id=new.folder_id ON CONFLICT(folder_id,uid) DO UPDATE SET revision=excluded.revision; END"), | |
| 92 | // P90.3 presentation-only fast path: a flags-only update never enqueues | ||
| 93 | // topology work; it only advances the folder revision. The stored | ||
| 94 | // unread_descendants aggregates of the ancestor chain are maintained | ||
| 95 | // by MailCache in the same transaction (SQLite trigger bodies cannot | ||
| 96 | // host the recursive ancestor walk). | ||
| 97 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_flags_au AFTER UPDATE ON headers WHEN old.folder_id IS new.folder_id AND old.uid IS new.uid AND old.subject IS new.subject AND old.from_addr IS new.from_addr AND old.to_addr IS new.to_addr AND old.date IS new.date AND old.size IS new.size AND old.has_attachments IS new.has_attachments AND old.message_id IS new.message_id AND old.in_reply_to IS new.in_reply_to AND old.ref_ids IS new.ref_ids AND old.is_spam IS new.is_spam AND old.flags IS NOT new.flags BEGIN UPDATE thread_revisions SET revision=revision+1 WHERE folder_id=new.folder_id; END"), | |
| 98 | // P90.3: labels never enter projection columns (pages join them live); | ||
| 99 | // a label change only advances the folder revision so cached pages and | ||
| 100 | // admission fences observe the new state without any topology work. | ||
| 101 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_labels_ai AFTER INSERT ON mail_labels BEGIN UPDATE thread_revisions SET revision=revision+1 WHERE folder_id=(SELECT folder_id FROM headers WHERE id=new.header_id); END"), | |
| 102 | 1538 | QStringLiteral("CREATE TRIGGER IF NOT EXISTS thread_labels_ad AFTER DELETE ON mail_labels BEGIN UPDATE thread_revisions SET revision=revision+1 WHERE folder_id=(SELECT folder_id FROM headers WHERE id=old.header_id); END") | |
| 103 |
2/4✓ Branch 290 → 291 taken 27684 times.
✓ Branch 290 → 292 taken 1538 times.
✗ Branch 536 → 537 not taken.
✗ Branch 536 → 538 not taken.
|
29222 | }; |
| 104 |
2/2✓ Branch 324 → 312 taken 27684 times.
✓ Branch 324 → 325 taken 1538 times.
|
29222 | for (const auto &sql : statements) { |
| 105 |
2/6✓ Branch 313 → 314 taken 27684 times.
✗ Branch 314 → 315 not taken.
✓ Branch 314 → 322 taken 27684 times.
✗ Branch 315 → 316 not taken.
✗ Branch 316 → 317 not taken.
✗ Branch 320 → 321 not taken.
|
27684 | if (!query.exec(sql)) { *error = query.lastError().text(); db.rollback(); return false; } |
| 106 | } | ||
| 107 |
2/6✓ Branch 325 → 327 taken 1538 times.
✗ Branch 327 → 328 not taken.
✓ Branch 327 → 335 taken 1538 times.
✗ Branch 328 → 329 not taken.
✗ Branch 329 → 330 not taken.
✗ Branch 333 → 334 not taken.
|
1538 | if (!db.commit()) { *error = db.lastError().text(); db.rollback(); return false; } |
| 108 | 1538 | return true; | |
| 109 | 32566 | } | |
| 110 |