MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 89.3% 616 / 0 / 690
Functions: 100.0% 36 / 0 / 36
Branches: 79.3% 944 / 35 / 1225

data/MailSearchIndex.cpp
Line Branch Exec Source
1 #include "data/MailSearchIndex.h"
2
3 // ═══════════════════════════════════════════════════════
4 // Full-Text Search (T-179)
5 // ═══════════════════════════════════════════════════════
6
7 #include "data/MailCacheSql.h"
8 #include "data/SqliteInterruptScope.h"
9 #include <QElapsedTimer>
10 #include <QLoggingCategory>
11 #include <QRegularExpression>
12 #include <QScopeGuard>
13 #include <QSqlDriver>
14 #include <QSqlError>
15 #include <QSqlQuery>
16 #include <QVariant>
17 #include <sqlite3.h>
18
19 // Same category as MailCache (defined there), see docs/LOGGING.md.
20 Q_DECLARE_LOGGING_CATEGORY(lcCache)
21
22 using MailCacheSql::beginWriteTransaction;
23 using MailCacheSql::decodeThreadIds;
24
25 5035 MailSearchIndex::MailSearchIndex(QSqlDatabase &db, QString &lastError, Deps deps)
26 5035 : m_db(db), m_lastError(lastError), m_deps(std::move(deps)) {}
27
28 namespace {
29 // Lightweight HTML → plain-text reduction for the search index. We only need
30 // searchable words, not faithful rendering, so a regex strip is enough and
31 // avoids pulling QtGui (QTextDocument) into the data layer. Used as a fallback
32 // for HTML-only mails whose text/plain part is empty.
33 413429 static QString stripHtmlForIndex(const QString &html) {
34
2/2
✓ Branch 3 → 4 taken 413367 times.
✓ Branch 3 → 5 taken 62 times.
413429 if (html.isEmpty())
35 413367 return QString();
36 62 QString s = html;
37 // Drop <script>/<style> blocks entirely (content is not human text).
38 static const QRegularExpression scriptStyle(
39 18 QStringLiteral("<(script|style)\\b[^>]*>.*?</\\1>"),
40 QRegularExpression::CaseInsensitiveOption |
41
4/7
✓ Branch 6 → 7 taken 9 times.
✓ Branch 6 → 19 taken 53 times.
✓ Branch 8 → 9 taken 9 times.
✗ Branch 8 → 19 not taken.
✓ Branch 13 → 14 taken 9 times.
✗ Branch 167 → 168 not taken.
✗ Branch 167 → 169 not taken.
71 QRegularExpression::DotMatchesEverythingOption);
42
1/1
✓ Branch 19 → 20 taken 62 times.
62 s.remove(scriptStyle);
43 // Strip all remaining tags.
44
4/7
✓ Branch 20 → 21 taken 9 times.
✓ Branch 20 → 33 taken 53 times.
✓ Branch 22 → 23 taken 9 times.
✗ Branch 22 → 33 not taken.
✓ Branch 27 → 28 taken 9 times.
✗ Branch 177 → 178 not taken.
✗ Branch 177 → 179 not taken.
71 static const QRegularExpression tags(QStringLiteral("<[^>]+>"));
45
1/1
✓ Branch 36 → 37 taken 62 times.
62 s.replace(tags, QStringLiteral(" "));
46 // Decode the handful of entities that actually matter for word matching.
47
1/1
✓ Branch 45 → 46 taken 62 times.
124 s.replace(QStringLiteral("&nbsp;"), QStringLiteral(" "));
48
1/1
✓ Branch 56 → 57 taken 62 times.
124 s.replace(QStringLiteral("&amp;"), QStringLiteral("&"));
49
1/1
✓ Branch 67 → 68 taken 62 times.
124 s.replace(QStringLiteral("&lt;"), QStringLiteral("<"));
50
1/1
✓ Branch 78 → 79 taken 62 times.
124 s.replace(QStringLiteral("&gt;"), QStringLiteral(">"));
51
1/1
✓ Branch 89 → 90 taken 62 times.
124 s.replace(QStringLiteral("&quot;"), QStringLiteral("\""));
52
1/1
✓ Branch 100 → 101 taken 62 times.
124 s.replace(QStringLiteral("&#39;"), QStringLiteral("'"));
53 // Numeric entities (&#228; / &#xE4;) → the actual character.
54 static const QRegularExpression numEntity(
55
4/7
✓ Branch 105 → 106 taken 9 times.
✓ Branch 105 → 118 taken 53 times.
✓ Branch 107 → 108 taken 9 times.
✗ Branch 107 → 118 not taken.
✓ Branch 112 → 113 taken 9 times.
✗ Branch 265 → 266 not taken.
✗ Branch 265 → 267 not taken.
71 QStringLiteral("&#(x?[0-9a-fA-F]+);"));
56
1/1
✓ Branch 119 → 120 taken 62 times.
62 QRegularExpressionMatchIterator it = numEntity.globalMatch(s);
57 // Build replacements without invalidating offsets: collect then apply.
58 62 QString result;
59
1/1
✓ Branch 121 → 122 taken 62 times.
62 result.reserve(s.size());
60 62 int last = 0;
61
3/3
✓ Branch 149 → 150 taken 66 times.
✓ Branch 150 → 123 taken 4 times.
✓ Branch 150 → 151 taken 62 times.
66 while (it.hasNext()) {
62
1/1
✓ Branch 123 → 124 taken 4 times.
4 const QRegularExpressionMatch m = it.next();
63
2/2
✓ Branch 125 → 126 taken 4 times.
✓ Branch 127 → 128 taken 4 times.
4 result += QStringView{s}.mid(last, m.capturedStart() - last);
64
1/1
✓ Branch 128 → 129 taken 4 times.
4 QString num = m.captured(1);
65 4 bool ok = false;
66
1/1
✓ Branch 131 → 132 taken 4 times.
4 uint code = num.startsWith(QLatin1Char('x'), Qt::CaseInsensitive)
67
4/6
✓ Branch 132 → 133 taken 2 times.
✓ Branch 132 → 136 taken 2 times.
✓ Branch 133 → 134 taken 2 times.
✓ Branch 134 → 135 taken 2 times.
✗ Branch 270 → 271 not taken.
✗ Branch 270 → 272 not taken.
6 ? num.mid(1).toUInt(&ok, 16)
68
3/3
✓ Branch 136 → 137 taken 2 times.
✓ Branch 138 → 139 taken 2 times.
✓ Branch 138 → 140 taken 2 times.
4 : num.toUInt(&ok, 10);
69
2/4
✓ Branch 140 → 141 taken 4 times.
✗ Branch 140 → 145 not taken.
✓ Branch 141 → 142 taken 4 times.
✗ Branch 141 → 145 not taken.
4 if (ok && code > 0)
70
1/1
✓ Branch 143 → 144 taken 4 times.
4 result += QChar(code);
71
1/1
✓ Branch 145 → 146 taken 4 times.
4 last = m.capturedEnd();
72 4 }
73
1/1
✓ Branch 153 → 154 taken 62 times.
62 result += QStringView{s}.mid(last);
74
1/1
✓ Branch 154 → 155 taken 62 times.
62 return result.simplified();
75 62 }
76
77 // S106-208: SQLite LIKE folds ASCII letters only, so a non-ASCII needle
78 // ("MÜLLER" for "Müller") never matched from:/to:/list:. Such a needle is
79 // matched on mailjd_fold(column) — Unicode case folding, the semantics of
80 // the in-memory matcher's Qt::CaseInsensitive; an ASCII needle keeps the
81 // plain LIKE (no per-row function call on the common path).
82 536 bool needsUnicodeFold(const QString &needle) {
83
2/2
✓ Branch 8 → 4 taken 4390 times.
✓ Branch 8 → 9 taken 528 times.
4918 for (const QChar ch : needle)
84
2/2
✓ Branch 5 → 6 taken 8 times.
✓ Branch 5 → 7 taken 4382 times.
4390 if (ch.unicode() > 127) return true;
85 528 return false;
86 }
87 268 QString likeColumn(const QString &column, const QString &needle) {
88
7/11
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 9 taken 264 times.
✓ Branch 8 → 10 taken 4 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 12 taken 264 times.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 14 taken 264 times.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 23 not taken.
272 return needsUnicodeFold(needle) ? QStringLiteral("mailjd_fold(%1)").arg(column) : column;
89 }
90 268 QString likeNeedle(const QString &needle) {
91
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 264 times.
268 return needsUnicodeFold(needle) ? needle.toCaseFolded() : needle;
92 }
93 } // namespace
94
95 1965061 QString MailSearchIndex::foldForSearch(const QString &text) {
96 // Lowercase first, then expand ß (no canonical decomposition) to "ss".
97
1/1
✓ Branch 2 → 3 taken 1965061 times.
1965061 QString lower = text.toLower();
98
1/1
✓ Branch 7 → 8 taken 1965061 times.
1965061 lower.replace(QChar(0x00DF), QStringLiteral("ss")); // ß → ss
99 // NFD decomposition splits accented letters into base + combining marks
100 // (e.g. "ü" → "u" + ¨). Dropping the combining marks yields the base letter.
101
1/1
✓ Branch 10 → 11 taken 1965061 times.
1965061 const QString decomposed = lower.normalized(QString::NormalizationForm_D);
102 1965061 QString out;
103
1/1
✓ Branch 12 → 13 taken 1965061 times.
1965061 out.reserve(decomposed.size());
104
2/2
✓ Branch 22 → 15 taken 63962942 times.
✓ Branch 22 → 23 taken 1965061 times.
65928003 for (const QChar c : decomposed) {
105 63962942 const QChar::Category cat = c.category();
106
4/6
✓ Branch 16 → 17 taken 63864797 times.
✓ Branch 16 → 19 taken 98145 times.
✓ Branch 17 → 18 taken 63864797 times.
✗ Branch 17 → 19 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 63864797 times.
63962942 if (cat == QChar::Mark_NonSpacing || cat == QChar::Mark_SpacingCombining ||
107 cat == QChar::Mark_Enclosing)
108 98145 continue; // strip diacritic / combining marks
109
1/1
✓ Branch 20 → 21 taken 63864797 times.
63864797 out.append(c);
110 }
111 1965061 return out;
112 1965061 }
113
114 // SEC-2026-07-21-22: Escape SQL LIKE wildcards in user-supplied search terms
115 // so %, _ and \ are treated as literals, not as pattern metacharacters. Used
116 // in conjunction with "ESCAPE '\'" appended to each LIKE clause.
117 516 QString MailSearchIndex::escapeLikePattern(const QString &raw) {
118 516 QString escaped;
119
1/1
✓ Branch 3 → 4 taken 516 times.
516 escaped.reserve(raw.size() + 4);
120
2/2
✓ Branch 27 → 6 taken 3523 times.
✓ Branch 27 → 28 taken 516 times.
4039 for (const QChar &c : raw) {
121
6/6
✓ Branch 9 → 10 taken 3522 times.
✓ Branch 9 → 18 taken 1 time.
✓ Branch 13 → 14 taken 3521 times.
✓ Branch 13 → 18 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 19 taken 3520 times.
7044 if (c == QLatin1Char('%') || c == QLatin1Char('_') ||
122
2/2
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 25 taken 3520 times.
7044 c == QLatin1Char('\\'))
123
1/1
✓ Branch 23 → 24 taken 3 times.
3 escaped += QLatin1Char('\\');
124
1/1
✓ Branch 25 → 26 taken 3523 times.
3523 escaped += c;
125 }
126 516 return escaped;
127 ✗ }
128
129 QList<MailSearchIndex::SearchResult>
130 140 MailSearchIndex::searchFts(const QString &query, int maxResults, int offset) const {
131
1/7
✓ Branch 5 → 6 taken 140 times.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✗ Branch 19 → 20 not taken.
✗ Branch 19 → 21 not taken.
140 return searchFts(query, SearchFilter{}, maxResults, offset);
132 }
133
134 QList<MailSearchIndex::SearchResult>
135 191 MailSearchIndex::searchFts(const QString &query, const SearchFilter &filter,
136 int maxResults, int offset,
137 const std::optional<QString> &accountId, QString *error) const {
138 191 return searchFtsImpl(query, filter, maxResults, offset, accountId, error, nullptr);
139 }
140
141 547 MailSearchPage MailSearchIndex::searchPage(const QString &query, const SearchFilter &filter,
142 const std::optional<QString> &accountId, int limit, int offset,
143 const std::atomic_bool &cancelled) const {
144 547 MailSearchPage page;
145
5/7
✓ Branch 3 → 4 taken 546 times.
✓ Branch 3 → 12 taken 1 time.
✓ Branch 4 → 5 taken 546 times.
✗ Branch 4 → 12 not taken.
✓ Branch 5 → 6 taken 546 times.
✓ Branch 6 → 7 taken 546 times.
✗ Branch 6 → 12 not taken.
547 if (limit <= 0 || limit > MailSearchPage::MaxRows || offset < 0 || !filter.fitsReadLimits(query)
146
6/8
✓ Branch 2 → 3 taken 547 times.
✗ Branch 2 → 12 not taken.
✓ Branch 8 → 9 taken 519 times.
✓ Branch 8 → 13 taken 27 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 519 times.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 22 taken 546 times.
1094 || (accountId && accountId->size() > 4096)) {
147 1 page.outcome = MailSearchPage::Outcome::Limited;
148 1 page.error = QStringLiteral("Search request exceeds the bounded page contract");
149 1 return page;
150 }
151
2/3
✓ Branch 22 → 23 taken 546 times.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 31 taken 546 times.
546 if (!m_db.isOpen()) { page.error = QStringLiteral("Search database is not open"); return page; }
152
2/2
✓ Branch 32 → 33 taken 5 times.
✓ Branch 32 → 34 taken 541 times.
546 if (cancelled.load()) { page.outcome = MailSearchPage::Outcome::Cancelled; return page; }
153
1/1
✓ Branch 34 → 35 taken 541 times.
541 SqliteInterruptScope interrupt(m_db, cancelled, 5000);
154
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 44 taken 541 times.
541 if (!interrupt.valid()) {
155 ✗ page.error = QStringLiteral("Search reader requires an interruptible SQLite connection");
156 ✗ return page;
157 }
158
1/1
✓ Branch 44 → 45 taken 541 times.
541 searchFtsImpl(query, filter, limit, offset, accountId, &page.error, &page);
159
5/6
✓ Branch 47 → 48 taken 520 times.
✓ Branch 47 → 50 taken 21 times.
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 520 times.
✓ Branch 52 → 53 taken 21 times.
✓ Branch 52 → 67 taken 520 times.
541 if (cancelled.load() || interrupt.timedOut()) {
160
1/1
✓ Branch 53 → 54 taken 21 times.
21 page.hits.clear();
161
1/2
✓ Branch 55 → 56 taken 21 times.
✗ Branch 55 → 57 not taken.
21 page.outcome = cancelled.load() ? MailSearchPage::Outcome::Cancelled : MailSearchPage::Outcome::Limited;
162
1/2
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 73 taken 21 times.
21 if (interrupt.timedOut()) page.error = QStringLiteral("Search exceeded the worker time limit");
163
2/2
✓ Branch 67 → 68 taken 517 times.
✓ Branch 67 → 73 taken 3 times.
520 } else if (page.outcome != MailSearchPage::Outcome::Limited) {
164
2/2
✓ Branch 69 → 70 taken 515 times.
✓ Branch 69 → 71 taken 2 times.
517 page.outcome = page.error.isEmpty() ? MailSearchPage::Outcome::Ready : MailSearchPage::Outcome::Failed;
165 }
166
3/3
✓ Branch 73 → 74 taken 26 times.
✓ Branch 73 → 76 taken 515 times.
✓ Branch 74 → 75 taken 26 times.
541 if (page.outcome != MailSearchPage::Outcome::Ready) { page.hits.clear(); page.chargedBytes = 0; }
167 541 return page;
168 541 }
169
170 // searchFtsImpl step: fold the query exactly like the indexed text and
171 // split it into FTS5 literals (>= 3 characters, trigram) and short LIKE terms.
172 1303 void MailSearchIndex::splitSearchTerms(const QString &query, QStringList &matchTerms,
173 QStringList &likeTerms) {
174
2/2
✓ Branch 2 → 3 taken 1303 times.
✓ Branch 3 → 4 taken 1303 times.
1303 const QString folded = foldForSearch(query).trimmed();
175 const QStringList words =
176
1/1
✓ Branch 10 → 11 taken 1303 times.
3909 folded.split(QRegularExpression(QStringLiteral("\\s+")),
177
1/1
✓ Branch 11 → 12 taken 1303 times.
1303 Qt::SkipEmptyParts);
178
2/2
✓ Branch 43 → 17 taken 863 times.
✓ Branch 43 → 44 taken 1303 times.
2166 for (QString w : words) {
179
2/2
✓ Branch 20 → 21 taken 80 times.
✓ Branch 20 → 22 taken 783 times.
863 if (w.size() < 3) {
180
1/1
✓ Branch 21 → 40 taken 80 times.
80 likeTerms.append(w);
181 } else {
182
1/1
✓ Branch 27 → 28 taken 783 times.
783 w.replace(QLatin1Char('"'), QStringLiteral("\"\""));
183
3/3
✓ Branch 34 → 35 taken 783 times.
✓ Branch 35 → 36 taken 783 times.
✓ Branch 36 → 37 taken 783 times.
783 matchTerms.append(QLatin1Char('"') + w + QLatin1Char('"'));
184 }
185 863 }
186 1303 }
187
188 // searchFtsImpl step: WHERE conditions of the text term and all facets.
189 1242 QStringList MailSearchIndex::searchConditions(const SearchFilter &filter, bool accountScoped,
190 bool useMatch, const QStringList &likeTerms,
191 const QStringList &folderPatterns) {
192 // Collect WHERE conditions, starting from a constant so we can always append
193 // " AND ..." regardless of whether a text condition exists.
194 1242 QStringList where;
195
1/1
✓ Branch 5 → 6 taken 1242 times.
1242 where.append(QStringLiteral("1=1"));
196
3/3
✓ Branch 8 → 9 taken 1033 times.
✓ Branch 8 → 16 taken 209 times.
✓ Branch 12 → 13 taken 1033 times.
2275 if (accountScoped) where.append(QStringLiteral("fo.account = :accountScope"));
197
2/2
✓ Branch 16 → 17 taken 720 times.
✓ Branch 16 → 24 taken 522 times.
1242 if (useMatch) {
198
1/1
✓ Branch 20 → 21 taken 720 times.
720 where.append(QStringLiteral("mail_fts MATCH :query"));
199 }
200
2/2
✓ Branch 44 → 25 taken 62 times.
✓ Branch 44 → 45 taken 1242 times.
1304 for (int i = 0; i < likeTerms.size(); ++i) {
201
1/1
✓ Branch 29 → 30 taken 62 times.
124 const QString bind = QStringLiteral(":like%1").arg(i);
202
1/1
✓ Branch 37 → 38 taken 62 times.
186 where.append(QStringLiteral(
203 "(f.subject LIKE %1 ESCAPE '\\' OR "
204 "f.from_addr LIKE %1 ESCAPE '\\' OR "
205 "f.to_addr LIKE %1 ESCAPE '\\' OR "
206 "f.body_text LIKE %1 ESCAPE '\\')")
207
1/1
✓ Branch 36 → 37 taken 62 times.
124 .arg(bind));
208 62 }
209
210
2/2
✓ Branch 46 → 47 taken 65 times.
✓ Branch 46 → 78 taken 1177 times.
1242 if (!folderPatterns.isEmpty()) {
211 65 QStringList ors;
212
2/2
✓ Branch 59 → 48 taken 99 times.
✓ Branch 59 → 60 taken 65 times.
164 for (int i = 0; i < folderPatterns.size(); ++i)
213
2/2
✓ Branch 52 → 53 taken 99 times.
✓ Branch 53 → 54 taken 99 times.
198 ors.append(QStringLiteral("fo.path LIKE :folder%1 ESCAPE '\\'").arg(i));
214
3/3
✓ Branch 65 → 66 taken 65 times.
✓ Branch 68 → 69 taken 65 times.
✓ Branch 70 → 71 taken 65 times.
130 where.append(QLatin1Char('(') + ors.join(QStringLiteral(" OR ")) +
215
1/1
✓ Branch 69 → 70 taken 65 times.
130 QLatin1Char(')'));
216 65 }
217
3/3
✓ Branch 78 → 79 taken 1242 times.
✓ Branch 79 → 80 taken 208 times.
✓ Branch 79 → 87 taken 1034 times.
1242 if (filter.dateFrom.isValid())
218
1/1
✓ Branch 83 → 84 taken 208 times.
208 where.append(QStringLiteral("h.date >= :dateFrom"));
219
3/3
✓ Branch 87 → 88 taken 1242 times.
✓ Branch 88 → 89 taken 203 times.
✓ Branch 88 → 96 taken 1039 times.
1242 if (filter.dateTo.isValid())
220
1/1
✓ Branch 92 → 93 taken 203 times.
203 where.append(QStringLiteral("h.date <= :dateTo"));
221
2/2
✓ Branch 97 → 98 taken 149 times.
✓ Branch 97 → 114 taken 1093 times.
1242 if (!filter.fromFilter.isEmpty())
222
2/2
✓ Branch 104 → 105 taken 149 times.
✓ Branch 106 → 107 taken 149 times.
447 where.append(likeColumn(QStringLiteral("h.from_addr"), filter.fromFilter) +
223
1/1
✓ Branch 105 → 106 taken 149 times.
447 QStringLiteral(" LIKE :fromFilter ESCAPE '\\'"));
224
2/2
✓ Branch 115 → 116 taken 31 times.
✓ Branch 115 → 139 taken 1211 times.
1242 if (!filter.toFilter.isEmpty())
225
1/1
✓ Branch 128 → 129 taken 31 times.
93 where.append(QStringLiteral("(%1 LIKE :toFilter ESCAPE '\\' OR %2 LIKE :ccFilter ESCAPE '\\')")
226
2/2
✓ Branch 126 → 127 taken 31 times.
✓ Branch 127 → 128 taken 31 times.
124 .arg(likeColumn(QStringLiteral("h.to_addr"), filter.toFilter),
227
1/1
✓ Branch 122 → 123 taken 31 times.
93 likeColumn(QStringLiteral("h.cc_addr"), filter.toFilter)));
228
2/2
✓ Branch 140 → 141 taken 12 times.
✓ Branch 140 → 157 taken 1230 times.
1242 if (!filter.listFilter.isEmpty())
229
2/2
✓ Branch 147 → 148 taken 12 times.
✓ Branch 149 → 150 taken 12 times.
36 where.append(likeColumn(QStringLiteral("h.list_id"), filter.listFilter) +
230
1/1
✓ Branch 148 → 149 taken 12 times.
36 QStringLiteral(" LIKE :listFilter ESCAPE '\\'"));
231
232 // Sprint 59 facets. Subject is matched against the folded FTS column so it
233 // stays diacritics-blind and consistent with the free-text term.
234
2/2
✓ Branch 158 → 159 taken 41 times.
✓ Branch 158 → 166 taken 1201 times.
1242 if (!filter.subjectFilter.isEmpty())
235
1/1
✓ Branch 162 → 163 taken 41 times.
41 where.append(QStringLiteral("f.subject LIKE :subject ESCAPE '\\'"));
236 // Flags as bitmask constraints. unread=Yes ⇒ the Seen bit is NOT set.
237
2/2
✓ Branch 166 → 167 taken 44 times.
✓ Branch 166 → 174 taken 1198 times.
1242 if (filter.unread == SearchFilter::Tri::Yes)
238
1/1
✓ Branch 170 → 171 taken 44 times.
44 where.append(QStringLiteral("(h.flags & :seenMask) = 0"));
239
2/2
✓ Branch 174 → 175 taken 10 times.
✓ Branch 174 → 182 taken 1188 times.
1198 else if (filter.unread == SearchFilter::Tri::No)
240
1/1
✓ Branch 178 → 179 taken 10 times.
10 where.append(QStringLiteral("(h.flags & :seenMask) = :seenMask"));
241
2/2
✓ Branch 182 → 183 taken 25 times.
✓ Branch 182 → 190 taken 1217 times.
1242 if (filter.flagged == SearchFilter::Tri::Yes)
242
1/1
✓ Branch 186 → 187 taken 25 times.
25 where.append(QStringLiteral("(h.flags & :flaggedMask) = :flaggedMask"));
243
2/2
✓ Branch 190 → 191 taken 17 times.
✓ Branch 190 → 198 taken 1200 times.
1217 else if (filter.flagged == SearchFilter::Tri::No)
244
1/1
✓ Branch 194 → 195 taken 17 times.
17 where.append(QStringLiteral("(h.flags & :flaggedMask) = 0"));
245
2/2
✓ Branch 198 → 199 taken 20 times.
✓ Branch 198 → 206 taken 1222 times.
1242 if (filter.answered == SearchFilter::Tri::Yes)
246
1/1
✓ Branch 202 → 203 taken 20 times.
20 where.append(QStringLiteral("(h.flags & :answeredMask) = :answeredMask"));
247
2/2
✓ Branch 206 → 207 taken 16 times.
✓ Branch 206 → 214 taken 1206 times.
1222 else if (filter.answered == SearchFilter::Tri::No)
248
1/1
✓ Branch 210 → 211 taken 16 times.
16 where.append(QStringLiteral("(h.flags & :answeredMask) = 0"));
249
2/2
✓ Branch 214 → 215 taken 35 times.
✓ Branch 214 → 222 taken 1207 times.
1242 if (filter.hasAttachment == SearchFilter::Tri::Yes)
250
1/1
✓ Branch 218 → 219 taken 35 times.
35 where.append(QStringLiteral("h.has_attachments = 1"));
251
2/2
✓ Branch 222 → 223 taken 25 times.
✓ Branch 222 → 230 taken 1182 times.
1207 else if (filter.hasAttachment == SearchFilter::Tri::No)
252
1/1
✓ Branch 226 → 227 taken 25 times.
25 where.append(QStringLiteral("h.has_attachments = 0"));
253 // Tags: one EXISTS subselect per label so semantics are AND ("has tag A and
254 // tag B"). Empty labels are skipped.
255
2/2
✓ Branch 257 → 231 taken 42 times.
✓ Branch 257 → 258 taken 1242 times.
1284 for (int i = 0; i < filter.tags.size(); ++i) {
256
2/3
✓ Branch 232 → 233 taken 42 times.
✗ Branch 235 → 236 not taken.
✓ Branch 235 → 237 taken 42 times.
42 if (filter.tags.at(i).trimmed().isEmpty())
257 ✗ continue;
258
1/1
✓ Branch 241 → 242 taken 42 times.
84 const QString bind = QStringLiteral(":tag%1").arg(i);
259
1/1
✓ Branch 249 → 250 taken 42 times.
126 where.append(QStringLiteral("EXISTS (SELECT 1 FROM mail_labels ml "
260 "WHERE ml.header_id = h.id AND ml.label = %1)")
261
1/1
✓ Branch 248 → 249 taken 42 times.
84 .arg(bind));
262 42 }
263 // Sprint 103 D-21: about:<name>, resolved to the linked Message-IDs.
264
2/2
✓ Branch 259 → 260 taken 6 times.
✓ Branch 259 → 298 taken 1236 times.
1242 if (!filter.aboutFilter.isEmpty()) {
265 6 QStringList ids;
266
4/4
✓ Branch 265 → 266 taken 8 times.
✓ Branch 266 → 267 taken 8 times.
✓ Branch 272 → 261 taken 8 times.
✓ Branch 272 → 273 taken 6 times.
22 for (int i = 0; i < filter.aboutMessageIds.size(); ++i) ids.append(QStringLiteral(":about%1").arg(i));
267
13/21
✓ Branch 274 → 275 taken 2 times.
✓ Branch 274 → 278 taken 4 times.
✓ Branch 284 → 285 taken 4 times.
✓ Branch 285 → 286 taken 4 times.
✓ Branch 286 → 287 taken 6 times.
✓ Branch 288 → 289 taken 4 times.
✓ Branch 288 → 290 taken 2 times.
✓ Branch 290 → 291 taken 4 times.
✓ Branch 290 → 292 taken 2 times.
✓ Branch 292 → 293 taken 4 times.
✓ Branch 292 → 294 taken 2 times.
✓ Branch 294 → 295 taken 2 times.
✓ Branch 294 → 296 taken 4 times.
✗ Branch 535 → 536 not taken.
✗ Branch 535 → 537 not taken.
✗ Branch 542 → 543 not taken.
✗ Branch 542 → 544 not taken.
✗ Branch 546 → 547 not taken.
✗ Branch 546 → 548 not taken.
✗ Branch 550 → 551 not taken.
✗ Branch 550 → 552 not taken.
12 where.append(ids.isEmpty() ? QStringLiteral("0") : QStringLiteral("h.message_id IN (%1)").arg(ids.join(QLatin1Char(','))));
268 6 }
269
2/2
✓ Branch 298 → 299 taken 1242 times.
✓ Branch 299 → 300 taken 1242 times.
1242 where.append(exclusionConditions(filter));
270 1242 return where;
271 ✗ }
272
273 // Sprint 101 S101.1.8 (D-14): the folded excluded words, split like the
274 // positive terms — FTS5 literals (>= 3 characters) and short LIKE words.
275 2477 void MailSearchIndex::splitExcludedTerms(const SearchFilter &filter, QStringList &matchTerms,
276 QStringList &likeTerms) {
277
2/2
✓ Branch 12 → 4 taken 60 times.
✓ Branch 12 → 13 taken 2477 times.
2537 for (const QString &term : filter.excludedTerms) {
278 60 QStringList match, like;
279
1/1
✓ Branch 5 → 6 taken 60 times.
60 splitSearchTerms(term, match, like);
280
1/1
✓ Branch 6 → 7 taken 60 times.
60 matchTerms.append(match);
281
1/1
✓ Branch 7 → 8 taken 60 times.
60 likeTerms.append(like);
282 60 }
283 2477 }
284
285 // searchConditions step: one NOT condition per excluded value. Words use the
286 // FTS index in a subquery (NOT IN), so an exclusion-only search never needs
287 // a positive MATCH term; nullable header columns compare as "".
288 1242 QStringList MailSearchIndex::exclusionConditions(const SearchFilter &filter) {
289 1242 QStringList where;
290 3726 const auto each = [&where](const QStringList &values, const QString &bind, const QString &condition) {
291
2/2
✓ Branch 18 → 3 taken 49 times.
✓ Branch 18 → 19 taken 3726 times.
3775 for (int i = 0; i < values.size(); ++i)
292
5/6
✓ Branch 4 → 5 taken 49 times.
✓ Branch 7 → 8 taken 49 times.
✗ Branch 7 → 16 not taken.
✓ Branch 10 → 11 taken 49 times.
✓ Branch 11 → 12 taken 49 times.
✓ Branch 12 → 13 taken 49 times.
98 if (!values.at(i).trimmed().isEmpty()) where.append(condition.arg(bind.arg(i)));
293 3726 };
294 // S106-208: the address exclusions fold like the positive facets.
295 3726 const auto eachAddress = [&where](const QStringList &values, const QString &bind,
296 const QStringList &columns) {
297
2/2
✓ Branch 58 → 3 taken 45 times.
✓ Branch 58 → 59 taken 3726 times.
3771 for (int i = 0; i < values.size(); ++i) {
298
2/3
✓ Branch 4 → 5 taken 45 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 45 times.
45 if (values.at(i).trimmed().isEmpty()) continue;
299 45 QStringList likes;
300
2/2
✓ Branch 36 → 11 taken 45 times.
✓ Branch 36 → 37 taken 45 times.
90 for (const QString &column : columns)
301
1/1
✓ Branch 25 → 26 taken 45 times.
135 likes.append(QStringLiteral("%1 LIKE %2 ESCAPE '\\'")
302
3/3
✓ Branch 22 → 23 taken 45 times.
✓ Branch 23 → 24 taken 45 times.
✓ Branch 24 → 25 taken 45 times.
225 .arg(likeColumn(QStringLiteral("coalesce(%1,'')").arg(column), values.at(i)),
303
1/1
✓ Branch 16 → 17 taken 45 times.
90 bind.arg(i)));
304
3/3
✓ Branch 44 → 45 taken 45 times.
✓ Branch 45 → 46 taken 45 times.
✓ Branch 46 → 47 taken 45 times.
135 where.append(QStringLiteral("NOT (%1)").arg(likes.join(QStringLiteral(" OR "))));
305 45 }
306 3726 };
307
3/5
✓ Branch 9 → 10 taken 1242 times.
✓ Branch 14 → 15 taken 1242 times.
✓ Branch 14 → 16 taken 1242 times.
✗ Branch 132 → 133 not taken.
✗ Branch 132 → 134 not taken.
2484 eachAddress(filter.excludedFrom, QStringLiteral(":xfrom%1"), {QStringLiteral("h.from_addr")});
308
3/5
✓ Branch 27 → 28 taken 1242 times.
✓ Branch 32 → 33 taken 2484 times.
✓ Branch 32 → 34 taken 1242 times.
✗ Branch 154 → 155 not taken.
✗ Branch 154 → 156 not taken.
3726 eachAddress(filter.excludedTo, QStringLiteral(":xto%1"),
309 1242 {QStringLiteral("h.to_addr"), QStringLiteral("h.cc_addr")});
310
1/1
✓ Branch 42 → 43 taken 1242 times.
2484 each(filter.excludedSubjects, QStringLiteral(":xsubject%1"), QStringLiteral("coalesce(f.subject,'') NOT LIKE %1 ESCAPE '\\'"));
311
1/1
✓ Branch 53 → 54 taken 1242 times.
2484 each(filter.excludedFolders, QStringLiteral(":xfolder%1"), QStringLiteral("fo.path NOT LIKE %1 ESCAPE '\\'"));
312
3/5
✓ Branch 65 → 66 taken 1242 times.
✓ Branch 70 → 71 taken 1242 times.
✓ Branch 70 → 72 taken 1242 times.
✗ Branch 203 → 204 not taken.
✗ Branch 203 → 205 not taken.
2484 eachAddress(filter.excludedLists, QStringLiteral(":xlist%1"), {QStringLiteral("h.list_id")});
313
1/1
✓ Branch 79 → 80 taken 1242 times.
1242 each(filter.excludedTags, QStringLiteral(":xtag%1"),
314 2484 QStringLiteral("NOT EXISTS (SELECT 1 FROM mail_labels xl WHERE xl.header_id = h.id AND xl.label = %1)"));
315 1242 QStringList matchTerms, likeTerms;
316
1/1
✓ Branch 84 → 85 taken 1242 times.
1242 splitExcludedTerms(filter, matchTerms, likeTerms);
317
2/2
✓ Branch 97 → 86 taken 21 times.
✓ Branch 97 → 98 taken 1242 times.
1263 for (int i = 0; i < matchTerms.size(); ++i)
318
2/2
✓ Branch 90 → 91 taken 21 times.
✓ Branch 91 → 92 taken 21 times.
42 where.append(QStringLiteral("h.id NOT IN (SELECT rowid FROM mail_fts WHERE mail_fts MATCH :xmatch%1)").arg(i));
319
2/2
✓ Branch 118 → 99 taken 9 times.
✓ Branch 118 → 119 taken 1242 times.
1251 for (int i = 0; i < likeTerms.size(); ++i)
320
1/1
✓ Branch 109 → 110 taken 9 times.
27 where.append(QStringLiteral("NOT (coalesce(f.subject,'') LIKE %1 ESCAPE '\\' OR coalesce(f.from_addr,'') LIKE %1 ESCAPE '\\' OR "
321 "coalesce(f.to_addr,'') LIKE %1 ESCAPE '\\' OR coalesce(f.body_text,'') LIKE %1 ESCAPE '\\')")
322
2/2
✓ Branch 107 → 108 taken 9 times.
✓ Branch 108 → 109 taken 9 times.
36 .arg(QStringLiteral(":xlike%1").arg(i)));
323 1242 return where;
324 9936 }
325
326 // bindSearchParameters step: the values of exclusionConditions().
327 1235 void MailSearchIndex::bindExclusions(QSqlQuery &q, const SearchFilter &filter) {
328 91 const auto contains = [](const QString &value) {
329
3/3
✓ Branch 4 → 5 taken 91 times.
✓ Branch 7 → 8 taken 91 times.
✓ Branch 8 → 9 taken 91 times.
182 return QLatin1Char('%') + escapeLikePattern(value) + QLatin1Char('%');
330 };
331 2470 const auto each = [&](const QStringList &values, const QString &bind, bool fold) {
332
2/2
✓ Branch 26 → 3 taken 37 times.
✓ Branch 26 → 27 taken 2470 times.
2507 for (int i = 0; i < values.size(); ++i)
333
2/3
✓ Branch 4 → 5 taken 37 times.
✓ Branch 7 → 8 taken 37 times.
✗ Branch 7 → 24 not taken.
37 if (!values.at(i).trimmed().isEmpty())
334
4/6
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 12 taken 37 times.
✗ Branch 11 → 14 not taken.
✓ Branch 14 → 15 taken 37 times.
✓ Branch 17 → 18 taken 37 times.
✓ Branch 18 → 19 taken 37 times.
74 q.bindValue(bind.arg(i), contains(fold ? foldForSearch(values.at(i)) : values.at(i)));
335 2470 };
336 // S106-208: the address needles as exclusionConditions() matches them.
337 3705 const auto eachAddress = [&](const QStringList &values, const QString &bind) {
338
2/2
✓ Branch 23 → 3 taken 45 times.
✓ Branch 23 → 24 taken 3705 times.
3750 for (int i = 0; i < values.size(); ++i)
339
2/3
✓ Branch 4 → 5 taken 45 times.
✓ Branch 7 → 8 taken 45 times.
✗ Branch 7 → 21 not taken.
45 if (!values.at(i).trimmed().isEmpty())
340
4/4
✓ Branch 10 → 11 taken 45 times.
✓ Branch 11 → 12 taken 45 times.
✓ Branch 14 → 15 taken 45 times.
✓ Branch 15 → 16 taken 45 times.
90 q.bindValue(bind.arg(i), contains(likeNeedle(values.at(i))));
341 4940 };
342
1/1
✓ Branch 5 → 6 taken 1235 times.
1235 eachAddress(filter.excludedFrom, QStringLiteral(":xfrom%1"));
343
1/1
✓ Branch 11 → 12 taken 1235 times.
1235 eachAddress(filter.excludedTo, QStringLiteral(":xto%1"));
344
1/1
✓ Branch 17 → 18 taken 1235 times.
1235 each(filter.excludedSubjects, QStringLiteral(":xsubject%1"), true);
345
1/1
✓ Branch 23 → 24 taken 1235 times.
1235 each(filter.excludedFolders, QStringLiteral(":xfolder%1"), false);
346
1/1
✓ Branch 29 → 30 taken 1235 times.
1235 eachAddress(filter.excludedLists, QStringLiteral(":xlist%1"));
347
2/2
✓ Branch 54 → 33 taken 12 times.
✓ Branch 54 → 55 taken 1235 times.
1247 for (int i = 0; i < filter.excludedTags.size(); ++i)
348
2/3
✓ Branch 34 → 35 taken 12 times.
✓ Branch 37 → 38 taken 12 times.
✗ Branch 37 → 52 not taken.
12 if (!filter.excludedTags.at(i).trimmed().isEmpty())
349
2/2
✓ Branch 45 → 46 taken 12 times.
✓ Branch 46 → 47 taken 12 times.
36 q.bindValue(QStringLiteral(":xtag%1").arg(i), filter.excludedTags.at(i));
350 1235 QStringList matchTerms, likeTerms;
351
1/1
✓ Branch 55 → 56 taken 1235 times.
1235 splitExcludedTerms(filter, matchTerms, likeTerms);
352
4/4
✓ Branch 64 → 65 taken 21 times.
✓ Branch 65 → 66 taken 21 times.
✓ Branch 72 → 57 taken 21 times.
✓ Branch 72 → 73 taken 1235 times.
1298 for (int i = 0; i < matchTerms.size(); ++i) q.bindValue(QStringLiteral(":xmatch%1").arg(i), matchTerms.at(i));
353
5/5
✓ Branch 76 → 77 taken 9 times.
✓ Branch 82 → 83 taken 9 times.
✓ Branch 83 → 84 taken 9 times.
✓ Branch 91 → 74 taken 9 times.
✓ Branch 91 → 92 taken 1235 times.
1262 for (int i = 0; i < likeTerms.size(); ++i) q.bindValue(QStringLiteral(":xlike%1").arg(i), contains(likeTerms.at(i)));
354 1235 }
355
356 // searchFtsImpl step: wrap the identity query of a bounded page so metadata
357 // is projected only for the limited candidates (CASE guards bound strings).
358 540 QString MailSearchIndex::searchPageProjection(QString sql) {
359 // Limit identities before projecting metadata. CASE guards stop oversized
360 // strings/label aggregates from entering Qt even when one row is rejected.
361 3780 const auto field = [](const QString &column) {
362
1/1
✓ Branch 6 → 7 taken 3780 times.
7560 return QStringLiteral("CASE WHEN length(CAST(%1 AS BLOB))<=16384 THEN %1 END").arg(column);
363 };
364 540 const QString labelBytes = QStringLiteral("(SELECT coalesce(sum(length(CAST(label AS BLOB))+8),0) FROM mail_labels WHERE header_id=h.id)");
365 540 const QString labelCount = QStringLiteral("(SELECT count(*) FROM mail_labels WHERE header_id=h.id)");
366 540 QStringList byteTerms;
367
2/2
✓ Branch 24 → 12 taken 3780 times.
✓ Branch 24 → 25 taken 540 times.
4320 for (const auto *column : {"h.subject", "h.from_addr", "h.to_addr", "h.message_id", "h.in_reply_to", "h.ref_ids", "fo.path"})
368
3/3
✓ Branch 16 → 17 taken 3780 times.
✓ Branch 17 → 18 taken 3780 times.
✓ Branch 18 → 19 taken 3780 times.
7560 byteTerms.append(QStringLiteral("coalesce(length(CAST(%1 AS BLOB)),0)").arg(column));
369
1/1
✓ Branch 25 → 26 taken 540 times.
540 byteTerms.append(labelBytes);
370 1080 const QString labels = QStringLiteral("CASE WHEN %1<=16384 AND %2<=256 THEN "
371 "(SELECT json_group_array(label) FROM (SELECT label FROM mail_labels WHERE header_id=h.id ORDER BY label)) END")
372
1/1
✓ Branch 29 → 30 taken 540 times.
540 .arg(labelBytes, labelCount);
373 1080 sql = QStringLiteral("WITH candidates AS MATERIALIZED (%1) SELECT h.id, %2, %3, 0, h.folder_id, h.uid, %4, ")
374
7/7
✓ Branch 48 → 49 taken 540 times.
✓ Branch 49 → 50 taken 540 times.
✓ Branch 50 → 51 taken 540 times.
✓ Branch 51 → 52 taken 540 times.
✓ Branch 52 → 53 taken 540 times.
✓ Branch 53 → 54 taken 540 times.
✓ Branch 54 → 55 taken 540 times.
1080 .arg(sql, field("h.subject"), field("h.from_addr"), field("fo.path"))
375
6/6
✓ Branch 41 → 42 taken 540 times.
✓ Branch 42 → 43 taken 540 times.
✓ Branch 44 → 45 taken 540 times.
✓ Branch 55 → 56 taken 540 times.
✓ Branch 56 → 57 taken 540 times.
✓ Branch 57 → 58 taken 540 times.
2160 + byteTerms.join('+') + ", " + field("h.to_addr")
376
4/4
✓ Branch 39 → 40 taken 540 times.
✓ Branch 40 → 41 taken 540 times.
✓ Branch 58 → 59 taken 540 times.
✓ Branch 59 → 60 taken 540 times.
2160 + ", h.date, h.flags, h.size, h.has_attachments, " + field("h.message_id")
377
8/8
✓ Branch 35 → 36 taken 540 times.
✓ Branch 36 → 37 taken 540 times.
✓ Branch 37 → 38 taken 540 times.
✓ Branch 38 → 39 taken 540 times.
✓ Branch 60 → 61 taken 540 times.
✓ Branch 61 → 62 taken 540 times.
✓ Branch 62 → 63 taken 540 times.
✓ Branch 63 → 64 taken 540 times.
2160 + ", " + field("h.in_reply_to") + ", " + field("h.ref_ids")
378
4/4
✓ Branch 64 → 65 taken 540 times.
✓ Branch 65 → 66 taken 540 times.
✓ Branch 66 → 67 taken 540 times.
✓ Branch 67 → 68 taken 540 times.
1080 + ", h.is_spam, " + labels + ", " + labelCount
379
1/1
✓ Branch 68 → 69 taken 540 times.
1620 + QStringLiteral(" FROM candidates c JOIN headers h ON h.id=c.id JOIN folders fo ON fo.id=h.folder_id ORDER BY h.date DESC,h.id DESC");
380 1080 return sql;
381 540 }
382
383 // searchFtsImpl step: bind the text term, paging and facet parameters.
384 1235 void MailSearchIndex::bindSearchParameters(QSqlQuery &q, const SearchFilter &filter,
385 const std::optional<QString> &accountId,
386 const QString &matchQuery,
387 const QStringList &likeTerms,
388 const QStringList &folderPatterns, int maxResults,
389 int offset) {
390
4/4
✓ Branch 3 → 4 taken 1029 times.
✓ Branch 3 → 12 taken 206 times.
✓ Branch 7 → 8 taken 1029 times.
✓ Branch 8 → 9 taken 1029 times.
2264 if (accountId) q.bindValue(":accountScope", *accountId);
391
2/2
✓ Branch 13 → 14 taken 713 times.
✓ Branch 13 → 21 taken 522 times.
1235 if (!matchQuery.isEmpty())
392
2/2
✓ Branch 16 → 17 taken 713 times.
✓ Branch 17 → 18 taken 713 times.
713 q.bindValue(":query", matchQuery);
393
2/2
✓ Branch 47 → 22 taken 62 times.
✓ Branch 47 → 48 taken 1235 times.
1297 for (int i = 0; i < likeTerms.size(); ++i) {
394
2/2
✓ Branch 36 → 37 taken 62 times.
✓ Branch 37 → 38 taken 62 times.
186 q.bindValue(QStringLiteral(":like%1").arg(i),
395
2/2
✓ Branch 26 → 27 taken 62 times.
✓ Branch 29 → 30 taken 62 times.
124 QLatin1Char('%') + escapeLikePattern(likeTerms.at(i)) +
396
1/1
✓ Branch 30 → 31 taken 62 times.
186 QLatin1Char('%'));
397 }
398
2/2
✓ Branch 48 → 49 taken 594 times.
✓ Branch 48 → 63 taken 641 times.
1235 if (maxResults > 0) {
399
2/2
✓ Branch 51 → 52 taken 594 times.
✓ Branch 52 → 53 taken 594 times.
594 q.bindValue(":limit", maxResults);
400
2/2
✓ Branch 55 → 56 taken 107 times.
✓ Branch 55 → 63 taken 487 times.
594 if (offset > 0)
401
2/2
✓ Branch 58 → 59 taken 107 times.
✓ Branch 59 → 60 taken 107 times.
107 q.bindValue(":offset", offset);
402 }
403
404 // Bind optional filter parameters
405
2/2
✓ Branch 89 → 64 taken 99 times.
✓ Branch 89 → 90 taken 1235 times.
1334 for (int i = 0; i < folderPatterns.size(); ++i)
406
2/2
✓ Branch 78 → 79 taken 99 times.
✓ Branch 79 → 80 taken 99 times.
297 q.bindValue(QStringLiteral(":folder%1").arg(i),
407
2/2
✓ Branch 68 → 69 taken 99 times.
✓ Branch 71 → 72 taken 99 times.
198 QLatin1Char('%') + escapeLikePattern(folderPatterns.at(i)) +
408
1/1
✓ Branch 72 → 73 taken 99 times.
297 QLatin1Char('%'));
409
2/2
✓ Branch 91 → 92 taken 208 times.
✓ Branch 91 → 100 taken 1027 times.
1235 if (filter.dateFrom.isValid())
410
3/3
✓ Branch 93 → 94 taken 208 times.
✓ Branch 95 → 96 taken 208 times.
✓ Branch 96 → 97 taken 208 times.
208 q.bindValue(":dateFrom", filter.dateFrom.toSecsSinceEpoch());
411
2/2
✓ Branch 101 → 102 taken 203 times.
✓ Branch 101 → 110 taken 1032 times.
1235 if (filter.dateTo.isValid())
412
3/3
✓ Branch 103 → 104 taken 203 times.
✓ Branch 105 → 106 taken 203 times.
✓ Branch 106 → 107 taken 203 times.
203 q.bindValue(":dateTo", filter.dateTo.toSecsSinceEpoch());
413
2/2
✓ Branch 111 → 112 taken 149 times.
✓ Branch 111 → 131 taken 1086 times.
1235 if (!filter.fromFilter.isEmpty())
414
2/2
✓ Branch 122 → 123 taken 149 times.
✓ Branch 123 → 124 taken 149 times.
149 q.bindValue(":fromFilter",
415
3/3
✓ Branch 115 → 116 taken 149 times.
✓ Branch 116 → 117 taken 149 times.
✓ Branch 119 → 120 taken 149 times.
298 QLatin1Char('%') + escapeLikePattern(likeNeedle(filter.fromFilter)) +
416
1/1
✓ Branch 120 → 121 taken 149 times.
447 QLatin1Char('%'));
417
2/2
✓ Branch 132 → 133 taken 31 times.
✓ Branch 132 → 152 taken 1204 times.
1235 if (!filter.toFilter.isEmpty())
418
2/2
✓ Branch 143 → 144 taken 31 times.
✓ Branch 144 → 145 taken 31 times.
31 q.bindValue(":toFilter",
419
3/3
✓ Branch 136 → 137 taken 31 times.
✓ Branch 137 → 138 taken 31 times.
✓ Branch 140 → 141 taken 31 times.
62 QLatin1Char('%') + escapeLikePattern(likeNeedle(filter.toFilter)) +
420
1/1
✓ Branch 141 → 142 taken 31 times.
93 QLatin1Char('%'));
421
2/2
✓ Branch 153 → 154 taken 31 times.
✓ Branch 153 → 173 taken 1204 times.
1235 if (!filter.toFilter.isEmpty())
422
2/2
✓ Branch 164 → 165 taken 31 times.
✓ Branch 165 → 166 taken 31 times.
31 q.bindValue(":ccFilter",
423
3/3
✓ Branch 157 → 158 taken 31 times.
✓ Branch 158 → 159 taken 31 times.
✓ Branch 161 → 162 taken 31 times.
62 QLatin1Char('%') + escapeLikePattern(likeNeedle(filter.toFilter)) +
424
1/1
✓ Branch 162 → 163 taken 31 times.
93 QLatin1Char('%'));
425
2/2
✓ Branch 174 → 175 taken 12 times.
✓ Branch 174 → 194 taken 1223 times.
1235 if (!filter.listFilter.isEmpty())
426
2/2
✓ Branch 185 → 186 taken 12 times.
✓ Branch 186 → 187 taken 12 times.
12 q.bindValue(":listFilter",
427
3/3
✓ Branch 178 → 179 taken 12 times.
✓ Branch 179 → 180 taken 12 times.
✓ Branch 182 → 183 taken 12 times.
24 QLatin1Char('%') + escapeLikePattern(likeNeedle(filter.listFilter)) +
428
1/1
✓ Branch 183 → 184 taken 12 times.
36 QLatin1Char('%'));
429
430 // Sprint 59 facet binds. Subject is folded so "müller" matches "Muller".
431
2/2
✓ Branch 195 → 196 taken 41 times.
✓ Branch 195 → 215 taken 1194 times.
1235 if (!filter.subjectFilter.isEmpty())
432
2/2
✓ Branch 206 → 207 taken 41 times.
✓ Branch 207 → 208 taken 41 times.
41 q.bindValue(":subject",
433 82 QLatin1Char('%') +
434
3/3
✓ Branch 199 → 200 taken 41 times.
✓ Branch 200 → 201 taken 41 times.
✓ Branch 203 → 204 taken 41 times.
164 escapeLikePattern(foldForSearch(filter.subjectFilter)) +
435
1/1
✓ Branch 204 → 205 taken 41 times.
123 QLatin1Char('%'));
436
2/2
✓ Branch 215 → 216 taken 54 times.
✓ Branch 215 → 223 taken 1181 times.
1235 if (filter.unread != SearchFilter::Tri::Any)
437
2/2
✓ Branch 218 → 219 taken 54 times.
✓ Branch 219 → 220 taken 54 times.
54 q.bindValue(":seenMask", static_cast<int>(MailFlag::Seen));
438
2/2
✓ Branch 223 → 224 taken 42 times.
✓ Branch 223 → 231 taken 1193 times.
1235 if (filter.flagged != SearchFilter::Tri::Any)
439
2/2
✓ Branch 226 → 227 taken 42 times.
✓ Branch 227 → 228 taken 42 times.
42 q.bindValue(":flaggedMask", static_cast<int>(MailFlag::Flagged));
440
2/2
✓ Branch 231 → 232 taken 36 times.
✓ Branch 231 → 239 taken 1199 times.
1235 if (filter.answered != SearchFilter::Tri::Any)
441
2/2
✓ Branch 234 → 235 taken 36 times.
✓ Branch 235 → 236 taken 36 times.
36 q.bindValue(":answeredMask", static_cast<int>(MailFlag::Answered));
442 {
443 1235 int tagIdx = 0;
444
2/2
✓ Branch 262 → 241 taken 42 times.
✓ Branch 262 → 263 taken 1235 times.
1277 for (const QString &tag : filter.tags) {
445
2/3
✓ Branch 242 → 243 taken 42 times.
✗ Branch 245 → 246 not taken.
✓ Branch 245 → 247 taken 42 times.
42 if (tag.trimmed().isEmpty()) {
446 ✗ ++tagIdx;
447 ✗ continue;
448 }
449
2/2
✓ Branch 253 → 254 taken 42 times.
✓ Branch 254 → 255 taken 42 times.
126 q.bindValue(QStringLiteral(":tag%1").arg(tagIdx), tag);
450 42 ++tagIdx;
451 }
452 }
453
2/2
✓ Branch 279 → 264 taken 8 times.
✓ Branch 279 → 280 taken 1235 times.
1243 for (int i = 0; i < filter.aboutMessageIds.size(); ++i)
454
2/2
✓ Branch 271 → 272 taken 8 times.
✓ Branch 272 → 273 taken 8 times.
24 q.bindValue(QStringLiteral(":about%1").arg(i), filter.aboutMessageIds.at(i));
455 1235 bindExclusions(q, filter);
456 1235 }
457
458 732 QList<MailSearchIndex::SearchResult> MailSearchIndex::searchFtsImpl(const QString &query,
459 const SearchFilter &filter, int maxResults, int offset,
460 const std::optional<QString> &accountId, QString *error, MailSearchPage *page) const {
461 732 QList<SearchResult> results;
462
2/2
✓ Branch 2 → 3 taken 548 times.
✓ Branch 2 → 4 taken 184 times.
732 if (error) error->clear();
463
3/3
✓ Branch 4 → 5 taken 732 times.
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 16 taken 729 times.
732 if (!m_db.isOpen()) {
464
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 14 taken 3 times.
3 if (error) *error = QStringLiteral("Search database is not open");
465 3 return results;
466 }
467
468 // Sprint 59: a search runs when there is a free-text term OR at least one
469 // facet. A completely empty query AND empty filter still returns nothing —
470 // we never silently "load everything".
471
1/1
✓ Branch 16 → 17 taken 729 times.
729 const bool hasFacets = !filter.isEmpty();
472
8/11
✓ Branch 17 → 18 taken 729 times.
✓ Branch 19 → 20 taken 244 times.
✓ Branch 19 → 22 taken 485 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 242 times.
✓ Branch 23 → 24 taken 729 times.
✗ Branch 23 → 25 not taken.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 28 taken 727 times.
✗ Branch 328 → 329 not taken.
✗ Branch 328 → 330 not taken.
729 if (query.trimmed().isEmpty() && !hasFacets)
473 2 return results;
474
475 // Fold the query exactly like the indexed text, then split into words.
476 // Trigram matches substrings but needs >= 3 characters per term, so collect
477 // the usable terms. Each is wrapped in an FTS5 string literal ("" escapes a
478 // quote) which both matches it as a literal substring and prevents query
479 // injection via FTS operators (T-618/SEC-18: OR, NOT, NEAR, column filters).
480 727 QStringList matchTerms;
481 727 QStringList likeTerms;
482
1/1
✓ Branch 28 → 29 taken 727 times.
727 splitSearchTerms(query, matchTerms, likeTerms);
483 727 const bool useMatch = !matchTerms.isEmpty();
484 727 const bool useLike = !likeTerms.isEmpty();
485
4/4
✓ Branch 31 → 32 taken 277 times.
✓ Branch 31 → 33 taken 450 times.
✓ Branch 32 → 33 taken 35 times.
✓ Branch 32 → 34 taken 242 times.
727 const bool hasText = useMatch || useLike;
486
3/4
✓ Branch 35 → 36 taken 242 times.
✓ Branch 35 → 39 taken 485 times.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 39 taken 242 times.
727 if (!hasText && !hasFacets)
487 ✗ return results; // nothing to constrain on
488
489
1/1
✓ Branch 39 → 40 taken 727 times.
727 QSqlQuery q(m_db);
490 // Read actual metadata from headers via JOIN so search results stay in sync
491 // with the canonical cache rows. Rank is not meaningful for trigram, so we
492 // order by recency (newest first) instead.
493
6/10
✓ Branch 43 → 44 taken 540 times.
✓ Branch 43 → 47 taken 187 times.
✓ Branch 52 → 53 taken 187 times.
✓ Branch 52 → 54 taken 540 times.
✓ Branch 54 → 55 taken 540 times.
✓ Branch 54 → 56 taken 187 times.
✗ Branch 334 → 335 not taken.
✗ Branch 334 → 336 not taken.
✗ Branch 338 → 339 not taken.
✗ Branch 338 → 340 not taken.
2181 QString sql = (page ? QStringLiteral("SELECT h.id ") : QStringLiteral(
494 "SELECT f.rowid, h.subject, h.from_addr, 0 AS rank, "
495
1/1
✓ Branch 50 → 51 taken 727 times.
2181 " h.folder_id, h.uid, fo.path ")) + QStringLiteral(
496 "FROM mail_fts f "
497 "JOIN headers h ON h.id = f.rowid "
498 "JOIN folders fo ON fo.id = h.folder_id ");
499
500 // Sprint 60 (B2): multiple folder patterns are OR-combined so a mail in ANY
501 // selected folder matches. One bind per non-empty pattern.
502 727 QStringList folderPatterns;
503
2/2
✓ Branch 68 → 60 taken 56 times.
✓ Branch 68 → 69 taken 727 times.
783 for (const QString &p : filter.folderPatterns)
504
2/3
✓ Branch 61 → 62 taken 56 times.
✓ Branch 64 → 65 taken 56 times.
✗ Branch 64 → 66 not taken.
56 if (!p.trimmed().isEmpty())
505
1/1
✓ Branch 65 → 66 taken 56 times.
56 folderPatterns.append(p);
506 const QStringList where =
507
1/1
✓ Branch 70 → 71 taken 727 times.
727 searchConditions(filter, accountId.has_value(), useMatch, likeTerms, folderPatterns);
508
509
3/3
✓ Branch 74 → 75 taken 727 times.
✓ Branch 78 → 79 taken 727 times.
✓ Branch 79 → 80 taken 727 times.
1454 sql += QStringLiteral("WHERE ") + where.join(QStringLiteral(" AND "));
510
1/1
✓ Branch 89 → 90 taken 727 times.
727 sql += QStringLiteral(" ORDER BY h.date DESC, h.id DESC");
511
2/2
✓ Branch 92 → 93 taken 597 times.
✓ Branch 92 → 107 taken 130 times.
727 if (maxResults > 0) {
512
1/1
✓ Branch 96 → 97 taken 597 times.
597 sql += QStringLiteral(" LIMIT :limit");
513
2/2
✓ Branch 99 → 100 taken 107 times.
✓ Branch 99 → 107 taken 490 times.
597 if (offset > 0)
514
1/1
✓ Branch 103 → 104 taken 107 times.
107 sql += QStringLiteral(" OFFSET :offset");
515 }
516
3/3
✓ Branch 107 → 108 taken 540 times.
✓ Branch 107 → 114 taken 187 times.
✓ Branch 109 → 110 taken 540 times.
727 if (page) sql = searchPageProjection(sql);
517
3/3
✓ Branch 114 → 115 taken 727 times.
✓ Branch 115 → 116 taken 6 times.
✓ Branch 115 → 125 taken 721 times.
727 if (!q.prepare(sql)) {
518
4/4
✓ Branch 116 → 117 taken 3 times.
✓ Branch 116 → 123 taken 3 times.
✓ Branch 117 → 118 taken 3 times.
✓ Branch 118 → 119 taken 3 times.
6 if (error) *error = q.lastError().text();
519 6 return results;
520 }
521
522
4/4
✓ Branch 125 → 126 taken 444 times.
✓ Branch 125 → 129 taken 277 times.
✓ Branch 128 → 130 taken 444 times.
✓ Branch 130 → 131 taken 721 times.
721 bindSearchParameters(q, filter, accountId, useMatch ? matchTerms.join(QLatin1Char(' ')) : QString(),
523 likeTerms, folderPatterns, maxResults, offset);
524
525
3/3
✓ Branch 132 → 133 taken 721 times.
✓ Branch 133 → 134 taken 18 times.
✓ Branch 133 → 158 taken 703 times.
721 if (!q.exec()) {
526
8/8
✓ Branch 134 → 135 taken 18 times.
✓ Branch 138 → 139 taken 18 times.
✓ Branch 139 → 140 taken 18 times.
✓ Branch 140 → 141 taken 18 times.
✓ Branch 141 → 142 taken 18 times.
✓ Branch 142 → 143 taken 18 times.
✓ Branch 148 → 136 taken 18 times.
✓ Branch 148 → 149 taken 18 times.
36 qCWarning(lcCache) << "FTS5 search failed:" << q.lastError().text();
527
3/4
✓ Branch 149 → 150 taken 18 times.
✗ Branch 149 → 156 not taken.
✓ Branch 150 → 151 taken 18 times.
✓ Branch 151 → 152 taken 18 times.
18 if (error) *error = q.lastError().text();
528 18 return results;
529 }
530
531 703 qint64 pageBytes = 0;
532
3/3
✓ Branch 304 → 305 taken 55511 times.
✓ Branch 305 → 159 taken 54811 times.
✓ Branch 305 → 306 taken 700 times.
55511 while (q.next()) {
533
2/2
✓ Branch 159 → 160 taken 54107 times.
✓ Branch 159 → 176 taken 704 times.
54811 if (page) {
534
2/2
✓ Branch 160 → 161 taken 54107 times.
✓ Branch 161 → 162 taken 54107 times.
54107 const auto bytes = q.value(7).toLongLong();
535
2/2
✓ Branch 163 → 164 taken 54107 times.
✓ Branch 164 → 165 taken 54107 times.
54107 const auto labels = q.value(18).toLongLong();
536 54107 pageBytes += 2 * bytes + 512 + 64 * labels;
537
6/6
✓ Branch 166 → 167 taken 54106 times.
✓ Branch 166 → 169 taken 1 time.
✓ Branch 167 → 168 taken 54105 times.
✓ Branch 167 → 169 taken 1 time.
✓ Branch 168 → 169 taken 1 time.
✓ Branch 168 → 176 taken 54104 times.
54107 if (bytes > MailSearchPage::MaxRowBytes || labels > 256 || pageBytes > MailSearchPage::MaxPageBytes) {
538 3 page->outcome = MailSearchPage::Outcome::Limited;
539 3 page->error = QStringLiteral("Search metadata exceeds the bounded page contract");
540 3 return {};
541 }
542 }
543 54808 SearchResult r;
544
2/2
✓ Branch 176 → 177 taken 54808 times.
✓ Branch 177 → 178 taken 54808 times.
54808 r.subject = q.value(1).toString();
545
2/2
✓ Branch 181 → 182 taken 54808 times.
✓ Branch 182 → 183 taken 54808 times.
54808 r.from = q.value(2).toString();
546
2/2
✓ Branch 186 → 187 taken 54808 times.
✓ Branch 187 → 188 taken 54808 times.
54808 r.rank = q.value(3).toDouble();
547
2/2
✓ Branch 189 → 190 taken 54808 times.
✓ Branch 190 → 191 taken 54808 times.
54808 r.folderId = q.value(4).toLongLong();
548
2/2
✓ Branch 192 → 193 taken 54808 times.
✓ Branch 193 → 194 taken 54808 times.
54808 r.uid = q.value(5).toLongLong();
549
2/2
✓ Branch 195 → 196 taken 54808 times.
✓ Branch 196 → 197 taken 54808 times.
54808 r.folderPath = q.value(6).toString();
550
2/2
✓ Branch 200 → 201 taken 54104 times.
✓ Branch 200 → 297 taken 704 times.
54808 if (page) {
551 54104 MailSearchHit hit;
552 54104 hit.folderPath = r.folderPath;
553 54104 auto &header = hit.header;
554 54104 header.folderId = r.folderId; header.uid = r.uid;
555 54104 header.subject = r.subject; header.from = r.from;
556
2/2
✓ Branch 205 → 206 taken 54104 times.
✓ Branch 206 → 207 taken 54104 times.
54104 header.to = q.value(8).toString();
557
7/7
✓ Branch 210 → 211 taken 54104 times.
✓ Branch 211 → 212 taken 54104 times.
✓ Branch 213 → 214 taken 54099 times.
✓ Branch 213 → 221 taken 5 times.
✓ Branch 214 → 215 taken 54099 times.
✓ Branch 215 → 216 taken 54099 times.
✓ Branch 216 → 217 taken 54099 times.
54104 if (!q.value(9).isNull()) header.date = QDateTime::fromSecsSinceEpoch(q.value(9).toLongLong());
558
4/4
✓ Branch 221 → 222 taken 54104 times.
✓ Branch 222 → 223 taken 54104 times.
✓ Branch 224 → 225 taken 54104 times.
✓ Branch 225 → 226 taken 54104 times.
54104 header.flags = q.value(10).toUInt(); header.size = q.value(11).toLongLong();
559
2/2
✓ Branch 227 → 228 taken 54104 times.
✓ Branch 228 → 229 taken 54104 times.
54104 header.hasAttachments = q.value(12).toBool();
560
3/3
✓ Branch 230 → 231 taken 54104 times.
✓ Branch 231 → 232 taken 54104 times.
✓ Branch 232 → 233 taken 54104 times.
54104 header.messageId = MessageId::canonicalFirst(q.value(13).toString());
561
3/3
✓ Branch 237 → 238 taken 54104 times.
✓ Branch 238 → 239 taken 54104 times.
✓ Branch 239 → 240 taken 54104 times.
54104 header.inReplyToIds = decodeThreadIds(q.value(14).toString());
562
3/3
✓ Branch 245 → 246 taken 12 times.
✓ Branch 245 → 248 taken 54092 times.
✓ Branch 246 → 247 taken 12 times.
54104 if (!header.inReplyToIds.isEmpty()) header.inReplyTo = header.inReplyToIds.first();
563
3/3
✓ Branch 248 → 249 taken 54104 times.
✓ Branch 249 → 250 taken 54104 times.
✓ Branch 250 → 251 taken 54104 times.
54104 header.references = decodeThreadIds(q.value(15).toString());
564 54104 pageBytes += 64 * (header.inReplyToIds.size() + header.references.size());
565
1/2
✗ Branch 257 → 258 not taken.
✓ Branch 257 → 266 taken 54104 times.
54104 if (pageBytes > MailSearchPage::MaxPageBytes) {
566 ✗ page->outcome = MailSearchPage::Outcome::Limited;
567 ✗ page->error = QStringLiteral("Search metadata exceeds the bounded page contract");
568 ✗ return {};
569 }
570
2/2
✓ Branch 266 → 267 taken 54104 times.
✓ Branch 267 → 268 taken 54104 times.
54104 header.isSpam = q.value(16).toBool();
571
4/4
✓ Branch 269 → 270 taken 54104 times.
✓ Branch 270 → 271 taken 54104 times.
✓ Branch 271 → 272 taken 54104 times.
✓ Branch 272 → 273 taken 54104 times.
54104 const auto labels = QJsonDocument::fromJson(q.value(17).toByteArray()).array();
572
5/5
✓ Branch 277 → 278 taken 54104 times.
✓ Branch 281 → 282 taken 8024 times.
✓ Branch 282 → 283 taken 8024 times.
✓ Branch 287 → 279 taken 8024 times.
✓ Branch 287 → 288 taken 54104 times.
62128 for (const auto &label : labels) header.labels.append(label.toString());
573
1/1
✓ Branch 289 → 290 taken 54104 times.
54104 page->hits.append(std::move(hit));
574 54104 page->chargedBytes = pageBytes;
575
2/3
✓ Branch 293 → 294 taken 54104 times.
✗ Branch 293 → 296 not taken.
✓ Branch 297 → 298 taken 704 times.
54808 } else results.append(r);
576 54808 }
577
4/4
✓ Branch 306 → 307 taken 700 times.
✓ Branch 307 → 308 taken 700 times.
✓ Branch 309 → 310 taken 1 time.
✓ Branch 309 → 318 taken 699 times.
700 if (q.lastError().isValid()) {
578
3/4
✓ Branch 310 → 311 taken 1 time.
✗ Branch 310 → 317 not taken.
✓ Branch 311 → 312 taken 1 time.
✓ Branch 312 → 313 taken 1 time.
1 if (error) *error = q.lastError().text();
579
1/1
✓ Branch 317 → 318 taken 1 time.
1 results.clear();
580 }
581
582 700 return results;
583 732 }
584
585 8 QStringList MailSearchIndex::knownLabels() const {
586 8 QStringList labels;
587
3/3
✓ Branch 2 → 3 taken 8 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 7 times.
8 if (!m_db.isOpen())
588 1 return labels;
589
590
1/1
✓ Branch 5 → 6 taken 7 times.
7 QSqlQuery q(m_db);
591 // DISTINCT labels, case-insensitive order so the UI suggestion list is stable.
592
3/3
✓ Branch 9 → 10 taken 7 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 29 taken 5 times.
7 if (!q.exec(QStringLiteral("SELECT DISTINCT label FROM mail_labels "
593 "ORDER BY label COLLATE NOCASE"))) {
594
8/8
✓ Branch 13 → 14 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 21 → 22 taken 2 times.
✓ Branch 27 → 15 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
4 qCWarning(lcCache) << "knownLabels query failed:" << q.lastError().text();
595 2 return labels;
596 }
597
3/3
✓ Branch 38 → 39 taken 11 times.
✓ Branch 39 → 30 taken 6 times.
✓ Branch 39 → 40 taken 5 times.
11 while (q.next()) {
598
2/2
✓ Branch 30 → 31 taken 6 times.
✓ Branch 31 → 32 taken 6 times.
6 const QString label = q.value(0).toString();
599
1/2
✓ Branch 34 → 35 taken 6 times.
✗ Branch 34 → 36 not taken.
6 if (!label.isEmpty())
600
1/1
✓ Branch 35 → 36 taken 6 times.
6 labels.append(label);
601 6 }
602 5 return labels;
603 7 }
604
605 33 void MailSearchIndex::indexForSearch(qint64 folderId, qint64 uid) {
606
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 32 times.
33 if (!m_db.isOpen())
607 1 return;
608
609 32 ++m_searchIndexDiagnostics.requests;
610
611 32 qint64 rowId = m_deps.headerRowId(folderId, uid);
612
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 31 times.
32 if (rowId <= 0)
613 1 return;
614
615
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 26 taken 29 times.
31 if (!beginWriteTransaction(m_db)) {
616 4 qCWarning(lcCache) << "Failed to start FTS index transaction:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; transaction result remains measured.
617
3/3
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
2 << m_db.lastError().text();
618 2 return;
619 }
620
621
1/2
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 30 taken 29 times.
29 if (!indexHeaderById(rowId)) {
622 ✗ m_db.rollback();
623 ✗ return;
624 }
625
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 49 taken 29 times.
29 if (!m_db.commit()) {
626 ✗ m_db.rollback();
627 ✗ qCWarning(lcCache) << "Failed to commit FTS index transaction:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; commit result remains measured.
628 ✗ << m_db.lastError().text();
629 }
630 }
631
632 45 bool MailSearchIndex::indexHeaderById(qint64 rowId) {
633
2/2
✓ Branch 2 → 3 taken 45 times.
✓ Branch 3 → 4 taken 45 times.
45 const auto documents = searchDocumentsByIds({rowId});
634
3/5
✓ Branch 6 → 7 taken 45 times.
✗ Branch 6 → 11 not taken.
✓ Branch 8 → 9 taken 45 times.
✓ Branch 9 → 10 taken 45 times.
✗ Branch 9 → 11 not taken.
90 return documents && replaceSearchDocuments(*documents);
635 45 }
636
637 std::optional<QList<MailSearchIndex::SearchDocument>>
638 15452 MailSearchIndex::searchDocumentsByIds(const QList<qint64> &rowIds) {
639 15452 QList<SearchDocument> documents;
640
1/1
✓ Branch 3 → 4 taken 15452 times.
15452 documents.reserve(rowIds.size());
641 15452 constexpr qsizetype QueryChunk = 400;
642
2/2
✓ Branch 113 → 5 taken 16256 times.
✓ Branch 113 → 114 taken 15451 times.
31707 for (qsizetype begin = 0; begin < rowIds.size(); begin += QueryChunk) {
643 16256 const qsizetype count = qMin(QueryChunk, rowIds.size() - begin);
644 16256 QStringList placeholders;
645
1/1
✓ Branch 7 → 8 taken 16256 times.
16256 placeholders.reserve(count);
646
2/2
✓ Branch 16 → 9 taken 411255 times.
✓ Branch 16 → 17 taken 16256 times.
427511 for (qsizetype i = 0; i < count; ++i)
647
1/1
✓ Branch 12 → 13 taken 411255 times.
411255 placeholders.append(QStringLiteral("?"));
648
1/1
✓ Branch 17 → 18 taken 16256 times.
16256 QSqlQuery query(m_db);
649
1/1
✓ Branch 26 → 27 taken 16256 times.
48768 if (!query.prepare(QStringLiteral(
650 "SELECT h.id, h.subject, h.from_addr, h.to_addr, "
651 "b.text_plain, b.text_html FROM headers h "
652 "LEFT JOIN bodies b ON b.header_id = h.id "
653 "WHERE h.id IN (%1) ORDER BY h.id")
654
4/4
✓ Branch 24 → 25 taken 16256 times.
✓ Branch 25 → 26 taken 16256 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 39 taken 16255 times.
48768 .arg(placeholders.join(QLatin1Char(','))))) {
655
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 33 → 34 taken 1 time.
1 m_lastError = query.lastError().text();
656 1 return std::nullopt;
657 }
658
2/2
✓ Branch 46 → 40 taken 411253 times.
✓ Branch 46 → 47 taken 16255 times.
427508 for (qsizetype i = 0; i < count; ++i)
659
1/1
✓ Branch 43 → 44 taken 411253 times.
411253 query.addBindValue(rowIds.at(begin + i));
660
2/3
✓ Branch 47 → 48 taken 16255 times.
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 67 taken 16255 times.
16255 if (!query.exec()) {
661 ✗ m_lastError = query.lastError().text();
662 ✗ qCWarning(lcCache) << "FTS document projection failed:" << m_lastError;
663 ✗ return std::nullopt;
664 }
665
3/3
✓ Branch 89 → 90 taken 427508 times.
✓ Branch 90 → 68 taken 411253 times.
✓ Branch 90 → 91 taken 16255 times.
427508 while (query.next()) {
666
2/2
✓ Branch 68 → 69 taken 411253 times.
✓ Branch 69 → 70 taken 411253 times.
1233759 documents.append({query.value(0).toLongLong(),
667 411253 query.value(1).toString(), query.value(2).toString(),
668 411253 query.value(3).toString(), query.value(4).toString(),
669 411253 query.value(5).toString()});
670 }
671
3/4
✓ Branch 91 → 92 taken 16255 times.
✓ Branch 92 → 93 taken 16255 times.
✗ Branch 94 → 95 not taken.
✓ Branch 94 → 102 taken 16255 times.
16255 if (query.lastError().isValid()) {
672 ✗ m_lastError = query.lastError().text();
673 ✗ return std::nullopt;
674 }
675 16257 }
676 15451 return documents;
677 426705 }
678
679 31228 bool MailSearchIndex::replaceSearchDocuments(
680 const QList<SearchDocument> &documents, bool deleteExisting) {
681 Q_UNUSED(deleteExisting)
682
1/1
✓ Branch 2 → 3 taken 31228 times.
31228 QSqlQuery upsert(m_db);
683
1/1
✓ Branch 6 → 7 taken 31228 times.
31228 upsert.prepare(QStringLiteral(
684 "INSERT INTO search_documents(header_id, subject, from_addr, to_addr, "
685 "body_text) VALUES(:rowid, :subject, :from, :to, :body) "
686 "ON CONFLICT(header_id) DO UPDATE SET subject=excluded.subject, "
687 "from_addr=excluded.from_addr, to_addr=excluded.to_addr, "
688 "body_text=excluded.body_text WHERE "
689 "search_documents.subject IS NOT excluded.subject OR "
690 "search_documents.from_addr IS NOT excluded.from_addr OR "
691 "search_documents.to_addr IS NOT excluded.to_addr OR "
692 "search_documents.body_text IS NOT excluded.body_text"));
693
694
2/2
✓ Branch 110 → 11 taken 429126 times.
✓ Branch 110 → 111 taken 31222 times.
460348 for (const SearchDocument &document : documents) {
695 429126 QString bodyText = document.textPlain;
696
3/3
✓ Branch 13 → 14 taken 429126 times.
✓ Branch 16 → 17 taken 413429 times.
✓ Branch 16 → 21 taken 15697 times.
429126 if (bodyText.trimmed().isEmpty())
697
1/1
✓ Branch 17 → 18 taken 413429 times.
413429 bodyText = stripHtmlForIndex(document.textHtml);
698
1/1
✓ Branch 26 → 27 taken 429126 times.
858252 upsert.bindValue(QStringLiteral(":rowid"), document.rowId);
699
1/1
✓ Branch 36 → 37 taken 429126 times.
858252 upsert.bindValue(QStringLiteral(":subject"),
700
1/1
✓ Branch 31 → 32 taken 429126 times.
858252 foldForSearch(document.subject));
701
2/2
✓ Branch 42 → 43 taken 429126 times.
✓ Branch 47 → 48 taken 429126 times.
858252 upsert.bindValue(QStringLiteral(":from"), foldForSearch(document.from));
702
2/2
✓ Branch 53 → 54 taken 429126 times.
✓ Branch 58 → 59 taken 429126 times.
858252 upsert.bindValue(QStringLiteral(":to"), foldForSearch(document.to));
703
2/2
✓ Branch 64 → 65 taken 429126 times.
✓ Branch 69 → 70 taken 429126 times.
858252 upsert.bindValue(QStringLiteral(":body"), foldForSearch(bodyText));
704
3/3
✓ Branch 74 → 75 taken 429126 times.
✓ Branch 75 → 76 taken 6 times.
✓ Branch 75 → 99 taken 429120 times.
429126 if (!upsert.exec()) {
705
2/2
✓ Branch 76 → 77 taken 6 times.
✓ Branch 77 → 78 taken 6 times.
6 m_lastError = upsert.lastError().text();
706
5/5
✓ Branch 81 → 82 taken 6 times.
✓ Branch 85 → 86 taken 6 times.
✓ Branch 86 → 87 taken 6 times.
✓ Branch 97 → 83 taken 6 times.
✓ Branch 97 → 98 taken 6 times.
12 qCWarning(lcCache) << "Search document update failed for header"
707
5/5
✓ Branch 87 → 88 taken 6 times.
✓ Branch 88 → 89 taken 6 times.
✓ Branch 89 → 90 taken 6 times.
✓ Branch 90 → 91 taken 6 times.
✓ Branch 91 → 92 taken 6 times.
6 << document.rowId << ":" << upsert.lastError().text();
708 6 return false;
709 }
710
3/3
✓ Branch 99 → 100 taken 429120 times.
✓ Branch 100 → 101 taken 429022 times.
✓ Branch 100 → 102 taken 98 times.
429120 if (upsert.numRowsAffected() > 0)
711 429022 ++m_searchIndexDiagnostics.replacements;
712 else
713 98 ++m_searchIndexDiagnostics.unchangedSkipped;
714 429126 }
715 31222 return true;
716 31228 }
717
718 // Batch-index multiple UIDs in a single transaction for performance
719 4 void MailSearchIndex::batchIndexForSearch(qint64 folderId, const QList<qint64> &uids) {
720
6/7
✓ Branch 2 → 3 taken 4 times.
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 6 taken 1 time.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 3 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 3 times.
4 if (!m_db.isOpen() || uids.isEmpty())
721 4 return;
722
723 3 m_searchIndexDiagnostics.requests += uids.size();
724
725
3/3
✓ Branch 11 → 12 taken 3 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 14 taken 1 time.
3 if (!beginWriteTransaction(m_db))
726 2 return;
727 1 QList<qint64> rowIds;
728
1/1
✓ Branch 15 → 16 taken 1 time.
1 rowIds.reserve(uids.size());
729
1/1
✓ Branch 16 → 17 taken 1 time.
1 QSqlQuery lookup(m_db);
730
1/1
✓ Branch 20 → 21 taken 1 time.
1 lookup.prepare(QStringLiteral(
731 "SELECT id FROM headers WHERE folder_id = :fid AND uid = :uid"));
732
1/2
✓ Branch 58 → 25 taken 1 time.
✗ Branch 58 → 59 not taken.
1 for (qint64 uid : uids) {
733
1/1
✓ Branch 31 → 32 taken 1 time.
2 lookup.bindValue(QStringLiteral(":fid"), folderId);
734
1/1
✓ Branch 40 → 41 taken 1 time.
2 lookup.bindValue(QStringLiteral(":uid"), uid);
735
2/3
✓ Branch 44 → 45 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
✗ Branch 45 → 48 not taken.
1 if (!lookup.exec()) {
736
1/1
✓ Branch 46 → 47 taken 1 time.
1 m_db.rollback();
737 1 return;
738 }
739 ✗ if (lookup.next())
740 ✗ rowIds.append(lookup.value(0).toLongLong());
741 ✗ lookup.finish();
742 }
743 ✗ const auto documents = searchDocumentsByIds(rowIds);
744 ✗ if (!documents || !replaceSearchDocuments(*documents)) {
745 ✗ m_db.rollback();
746 ✗ return;
747 }
748 ✗ if (!m_db.commit())
749 ✗ m_db.rollback();
750 2 }
751
752 16 bool MailSearchIndex::searchIndexEmpty() const {
753
3/3
✓ Branch 2 → 3 taken 16 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 15 times.
16 if (!m_db.isOpen()) return true;
754 // SEC-2026-07-21-23: if a previous rebuild was interrupted (crash/power
755 // failure after the autocommit DELETE but before all batches committed),
756 // the index is known-incomplete. Treat it as empty so the startup trigger
757 // rebuilds it.
758
1/1
✓ Branch 5 → 6 taken 15 times.
15 QSqlQuery metaQ(m_db);
759
3/3
✓ Branch 9 → 10 taken 15 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 12 → 29 taken 12 times.
15 if (!metaQ.exec(QStringLiteral(
760 "SELECT value FROM cache_meta WHERE key = 'fts_rebuild_in_progress'"))) {
761
5/5
✓ Branch 13 → 14 taken 3 times.
✓ Branch 17 → 18 taken 3 times.
✓ Branch 18 → 19 taken 3 times.
✓ Branch 27 → 15 taken 3 times.
✓ Branch 27 → 28 taken 3 times.
6 qCWarning(lcCache) << "Cannot verify FTS rebuild watermark:"
762
3/3
✓ Branch 19 → 20 taken 3 times.
✓ Branch 20 → 21 taken 3 times.
✓ Branch 21 → 22 taken 3 times.
3 << metaQ.lastError().text();
763 3 return true;
764 }
765
3/3
✓ Branch 29 → 30 taken 12 times.
✓ Branch 30 → 31 taken 3 times.
✓ Branch 30 → 42 taken 9 times.
12 if (metaQ.next()) {
766 6 qCWarning(lcCache) << "FTS index marked incomplete by interrupted rebuild"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; watermark check remains measured.
767 3 return true;
768 }
769
1/1
✓ Branch 42 → 43 taken 9 times.
9 QSqlQuery q(m_db);
770
2/8
✓ Branch 54 → 55 taken 9 times.
✗ Branch 54 → 56 not taken.
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 73 taken 9 times.
✗ Branch 104 → 105 not taken.
✗ Branch 104 → 106 not taken.
✗ Branch 108 → 109 not taken.
✗ Branch 108 → 110 not taken.
18 if (!q.exec(QStringLiteral(
771 "SELECT CAST(value AS INTEGER) FROM cache_meta "
772
3/5
✓ Branch 46 → 47 taken 9 times.
✓ Branch 47 → 48 taken 9 times.
✗ Branch 47 → 50 not taken.
✓ Branch 52 → 53 taken 9 times.
✗ Branch 52 → 54 not taken.
27 "WHERE key='fts_indexed_documents'")) ||
773
2/3
✓ Branch 48 → 49 taken 9 times.
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 9 times.
9 !q.next()) {
774 ✗ qCWarning(lcCache) << "Cannot verify FTS index contents:"
775 ✗ << q.lastError().text();
776 ✗ return true;
777 }
778
2/2
✓ Branch 73 → 74 taken 9 times.
✓ Branch 74 → 75 taken 9 times.
9 return q.value(0).toLongLong() == 0;
779 15 }
780
781 829 bool MailSearchIndex::searchIndexNeedsRebuild() const {
782
3/3
✓ Branch 2 → 3 taken 829 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 828 times.
829 if (!m_db.isOpen())
783 1 return true;
784
1/1
✓ Branch 5 → 6 taken 828 times.
828 QSqlQuery q(m_db);
785
3/8
✓ Branch 17 → 18 taken 828 times.
✗ Branch 17 → 19 not taken.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 36 taken 825 times.
✗ Branch 57 → 58 not taken.
✗ Branch 57 → 59 not taken.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
1656 if (!q.exec(QStringLiteral(
786 "SELECT "
787 "EXISTS(SELECT 1 FROM cache_meta "
788 "WHERE key='fts_rebuild_in_progress'), "
789 "(SELECT COUNT(*) FROM headers), "
790 "COALESCE((SELECT CAST(value AS INTEGER) FROM cache_meta "
791
4/5
✓ Branch 9 → 10 taken 828 times.
✓ Branch 10 → 11 taken 825 times.
✓ Branch 10 → 13 taken 3 times.
✓ Branch 15 → 16 taken 828 times.
✗ Branch 15 → 17 not taken.
2481 "WHERE key='fts_indexed_documents'), -1)")) ||
792
2/3
✓ Branch 11 → 12 taken 825 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 825 times.
825 !q.next()) {
793
5/5
✓ Branch 20 → 21 taken 3 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 25 → 26 taken 3 times.
✓ Branch 34 → 22 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
6 qCWarning(lcCache) << "Cannot determine FTS rebuild requirement:"
794
3/3
✓ Branch 26 → 27 taken 3 times.
✓ Branch 27 → 28 taken 3 times.
✓ Branch 28 → 29 taken 3 times.
3 << q.lastError().text();
795 3 return true;
796 }
797 // An interrupted rebuild always needs repair. A fresh empty mailbox does
798 // not: its empty authoritative data set and empty index already agree.
799
15/22
✓ Branch 36 → 37 taken 825 times.
✓ Branch 37 → 38 taken 825 times.
✓ Branch 38 → 39 taken 823 times.
✓ Branch 38 → 44 taken 2 times.
✓ Branch 39 → 40 taken 823 times.
✓ Branch 40 → 41 taken 823 times.
✓ Branch 41 → 42 taken 823 times.
✓ Branch 42 → 43 taken 823 times.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 822 times.
✓ Branch 47 → 48 taken 823 times.
✓ Branch 47 → 49 taken 2 times.
✓ Branch 49 → 50 taken 823 times.
✓ Branch 49 → 51 taken 2 times.
✓ Branch 51 → 52 taken 825 times.
✗ Branch 51 → 53 not taken.
✗ Branch 76 → 77 not taken.
✗ Branch 76 → 78 not taken.
✗ Branch 80 → 81 not taken.
✗ Branch 80 → 82 not taken.
✗ Branch 84 → 85 not taken.
✗ Branch 84 → 86 not taken.
825 return q.value(0).toBool() || q.value(1).toLongLong() != q.value(2).toLongLong();
800 828 }
801
802 // rebuildSearchIndex step: commit the durable rebuild watermark before the
803 // projection or the index change, so a crash never looks complete.
804 25 bool MailSearchIndex::markRebuildInProgress() {
805 // Commit the watermark before modifying the durable projection or index. A
806 // crash can therefore never expose an incomplete rebuild as complete.
807
3/3
✓ Branch 2 → 3 taken 25 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 20 taken 23 times.
25 if (!beginWriteTransaction(m_db)) {
808 4 qCWarning(lcCache) << "Failed to start FTS reset transaction:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; transaction result remains measured.
809
3/3
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
2 << m_db.lastError().text();
810 2 return false;
811 }
812
1/1
✓ Branch 20 → 21 taken 23 times.
23 QSqlQuery meta(m_db);
813
3/3
✓ Branch 24 → 25 taken 23 times.
✓ Branch 27 → 28 taken 3 times.
✓ Branch 27 → 45 taken 20 times.
23 if (!meta.exec(QStringLiteral(
814 "INSERT OR REPLACE INTO cache_meta (key, value) "
815 "VALUES ('fts_rebuild_in_progress', '1')"))) {
816
5/5
✓ Branch 28 → 29 taken 3 times.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 42 → 30 taken 3 times.
✓ Branch 42 → 43 taken 3 times.
6 qCWarning(lcCache) << "Failed to set FTS rebuild watermark:"
817
3/3
✓ Branch 34 → 35 taken 3 times.
✓ Branch 35 → 36 taken 3 times.
✓ Branch 36 → 37 taken 3 times.
3 << meta.lastError().text();
818
1/1
✓ Branch 43 → 44 taken 3 times.
3 m_db.rollback();
819 3 return false;
820 }
821
822
2/3
✓ Branch 45 → 46 taken 20 times.
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 64 taken 20 times.
20 if (!m_db.commit()) {
823 ✗ qCWarning(lcCache) << "Failed to commit FTS reset transaction:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; commit result remains measured.
824 ✗ << m_db.lastError().text();
825 ✗ m_db.rollback();
826 ✗ return false;
827 }
828 20 return true;
829 23 }
830
831 // rebuildSearchIndex step: repair a search_documents projection that lost
832 // rows (migration/repair only), in committed keyset batches.
833 20 bool MailSearchIndex::repairSearchDocuments(int headerCount, int documentCount,
834 int &repairedCount) {
835 // Migration/repair path only: stream the joined source through bounded
836 // keyset pages. In the steady state every data write maintains this
837 // projection atomically, so normal rebuilds avoid all C++ folding and row
838 // round trips.
839 20 constexpr int BATCH = kRepairBatchRows;
840 20 qint64 lastRowId = 0;
841
2/2
✓ Branch 140 → 3 taken 6 times.
✓ Branch 140 → 141 taken 17 times.
23 while (documentCount != headerCount) {
842 6 QList<SearchDocument> documents;
843
1/1
✓ Branch 3 → 4 taken 6 times.
6 documents.reserve(BATCH);
844
1/1
✓ Branch 4 → 5 taken 6 times.
6 QSqlQuery source(m_db);
845
1/1
✓ Branch 8 → 9 taken 6 times.
6 source.prepare(QStringLiteral(
846 "SELECT h.id, h.subject, h.from_addr, h.to_addr, b.text_plain, "
847 "b.text_html FROM headers h LEFT JOIN bodies b ON b.header_id = h.id "
848 "WHERE h.id > :last ORDER BY h.id LIMIT :limit"));
849
1/1
✓ Branch 16 → 17 taken 6 times.
12 source.bindValue(QStringLiteral(":last"), lastRowId);
850
1/1
✓ Branch 25 → 26 taken 6 times.
12 source.bindValue(QStringLiteral(":limit"), BATCH);
851
3/3
✓ Branch 29 → 30 taken 6 times.
✓ Branch 30 → 31 taken 1 time.
✓ Branch 30 → 47 taken 5 times.
6 if (!source.exec()) {
852
5/5
✓ Branch 31 → 32 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
✓ Branch 45 → 33 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
2 qCWarning(lcCache) << "Failed to stream FTS rebuild projection:"
853
3/3
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
1 << source.lastError().text();
854 1 return false;
855 }
856
3/3
✓ Branch 69 → 70 taken 2106 times.
✓ Branch 70 → 48 taken 2101 times.
✓ Branch 70 → 71 taken 5 times.
2106 while (source.next()) {
857
2/2
✓ Branch 48 → 49 taken 2101 times.
✓ Branch 49 → 50 taken 2101 times.
6303 documents.append({source.value(0).toLongLong(),
858 2101 source.value(1).toString(), source.value(2).toString(),
859 2101 source.value(3).toString(), source.value(4).toString(),
860 2101 source.value(5).toString()});
861 }
862
1/1
✓ Branch 71 → 72 taken 5 times.
5 source.finish();
863
2/2
✓ Branch 73 → 74 taken 1 time.
✓ Branch 73 → 75 taken 4 times.
5 if (documents.isEmpty())
864 1 break;
865 4 lastRowId = documents.constLast().rowId;
866
867
2/3
✓ Branch 76 → 77 taken 4 times.
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 94 taken 4 times.
4 if (!beginWriteTransaction(m_db)) {
868 ✗ qCWarning(lcCache) << "Failed to start FTS rebuild batch:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; transaction result remains measured.
869 ✗ << m_db.lastError().text();
870 ✗ return false;
871 }
872
3/3
✓ Branch 94 → 95 taken 4 times.
✓ Branch 95 → 96 taken 1 time.
✓ Branch 95 → 108 taken 3 times.
4 if (!replaceSearchDocuments(documents, false)) {
873
1/1
✓ Branch 96 → 97 taken 1 time.
1 m_db.rollback();
874
5/5
✓ Branch 97 → 98 taken 1 time.
✓ Branch 101 → 102 taken 1 time.
✓ Branch 102 → 103 taken 1 time.
✓ Branch 106 → 99 taken 1 time.
✓ Branch 106 → 107 taken 1 time.
2 qCWarning(lcCache) << "FTS5 index rebuild failed";
875 1 return false;
876 }
877
2/3
✓ Branch 108 → 109 taken 3 times.
✗ Branch 109 → 110 not taken.
✓ Branch 109 → 127 taken 3 times.
3 if (!m_db.commit()) {
878 ✗ m_db.rollback();
879 ✗ qCWarning(lcCache) << "FTS5 index rebuild commit failed:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; commit result remains measured.
880 ✗ << m_db.lastError().text();
881 ✗ return false;
882 }
883 3 repairedCount += documents.size();
884 9 }
885 18 return true;
886 2101 }
887
888 26 bool MailSearchIndex::rebuildSearchIndex() {
889
3/3
✓ Branch 2 → 3 taken 26 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 25 times.
26 if (!m_db.isOpen())
890 1 return false;
891
892
5/5
✓ Branch 5 → 6 taken 25 times.
✓ Branch 9 → 10 taken 25 times.
✓ Branch 10 → 11 taken 25 times.
✓ Branch 14 → 7 taken 25 times.
✓ Branch 14 → 15 taken 25 times.
50 qCInfo(lcCache) << "Rebuilding FTS5 search index...";
893
894
3/3
✓ Branch 15 → 16 taken 25 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 18 taken 20 times.
25 if (!markRebuildInProgress()) return false;
895
896
1/1
✓ Branch 18 → 19 taken 20 times.
20 QSqlQuery counts(m_db);
897
2/8
✓ Branch 30 → 31 taken 20 times.
✗ Branch 30 → 32 not taken.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 49 taken 20 times.
✗ Branch 286 → 287 not taken.
✗ Branch 286 → 288 not taken.
✗ Branch 290 → 291 not taken.
✗ Branch 290 → 292 not taken.
40 if (!counts.exec(QStringLiteral(
898 "SELECT (SELECT COUNT(*) FROM headers), "
899
3/5
✓ Branch 22 → 23 taken 20 times.
✓ Branch 23 → 24 taken 20 times.
✗ Branch 23 → 26 not taken.
✓ Branch 28 → 29 taken 20 times.
✗ Branch 28 → 30 not taken.
60 "(SELECT COUNT(*) FROM search_documents)")) ||
900
2/3
✓ Branch 24 → 25 taken 20 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 20 times.
20 !counts.next()) {
901 ✗ qCWarning(lcCache) << "Failed to inspect search document completeness:"
902 ✗ << counts.lastError().text();
903 ✗ return false;
904 }
905
2/2
✓ Branch 49 → 50 taken 20 times.
✓ Branch 50 → 51 taken 20 times.
20 const int headerCount = counts.value(0).toInt();
906
2/2
✓ Branch 52 → 53 taken 20 times.
✓ Branch 53 → 54 taken 20 times.
20 const int documentCount = counts.value(1).toInt();
907
908 20 int repairedCount = 0;
909
3/3
✓ Branch 55 → 56 taken 20 times.
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 58 taken 18 times.
20 if (!repairSearchDocuments(headerCount, documentCount, repairedCount)) return false;
910
911 // Keep SQLite's bounded page cache, but do not spill its dirty FTS pages in
912 // the middle of this single derived-index transaction. Mid-transaction
913 // spills add duplicate writeback and fsync pressure; the normal commit still
914 // uses synchronous=NORMAL and the durable rebuild marker makes an
915 // interrupted index fail closed. Restore the connection policy on every
916 // exit so foreground writes retain SQLite's default memory behavior.
917
1/1
✓ Branch 58 → 59 taken 18 times.
18 QSqlQuery cachePolicy(m_db);
918
2/3
✓ Branch 62 → 63 taken 18 times.
✗ Branch 65 → 66 not taken.
✓ Branch 65 → 82 taken 18 times.
18 if (!cachePolicy.exec(QStringLiteral("PRAGMA cache_spill=OFF"))) {
919 ✗ qCWarning(lcCache) << "Failed to suspend SQLite cache spilling:"
920 ✗ << cachePolicy.lastError().text();
921 ✗ return false;
922 }
923 18 const auto restoreCacheSpill = [&cachePolicy]() {
924
2/3
✓ Branch 5 → 6 taken 18 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 25 taken 18 times.
36 if (!cachePolicy.exec(QStringLiteral("PRAGMA cache_spill=ON")))
925 ✗ qCWarning(lcCache) << "Failed to restore SQLite cache spilling:"
926 ✗ << cachePolicy.lastError().text();
927 18 };
928
929
2/3
✓ Branch 82 → 83 taken 18 times.
✗ Branch 83 → 84 not taken.
✓ Branch 83 → 86 taken 18 times.
18 if (!beginWriteTransaction(m_db)) {
930 ✗ restoreCacheSpill();
931 ✗ return false;
932 }
933
1/1
✓ Branch 86 → 87 taken 18 times.
18 QSqlQuery rebuild(m_db);
934 // FTS5's default incremental automerge repeatedly rewrites newly created
935 // segments while a complete index is being populated. Disable it only for
936 // this explicit full rebuild, then restore the documented default before
937 // commit. The resulting bounded segment fan-out keeps query latency stable
938 // while avoiding the synchronous merge amplification.
939
3/3
✓ Branch 90 → 91 taken 18 times.
✓ Branch 93 → 94 taken 2 times.
✓ Branch 93 → 112 taken 16 times.
18 if (!rebuild.exec(QStringLiteral(
940 "INSERT INTO mail_fts(mail_fts, rank) VALUES('automerge', 0)"))) {
941
5/5
✓ Branch 94 → 95 taken 2 times.
✓ Branch 98 → 99 taken 2 times.
✓ Branch 99 → 100 taken 2 times.
✓ Branch 108 → 96 taken 2 times.
✓ Branch 108 → 109 taken 2 times.
4 qCWarning(lcCache) << "Failed to suspend FTS5 automerge:"
942
3/3
✓ Branch 100 → 101 taken 2 times.
✓ Branch 101 → 102 taken 2 times.
✓ Branch 102 → 103 taken 2 times.
2 << rebuild.lastError().text();
943
1/1
✓ Branch 109 → 110 taken 2 times.
2 m_db.rollback();
944
1/1
✓ Branch 110 → 111 taken 2 times.
2 restoreCacheSpill();
945 2 return false;
946 }
947
2/3
✓ Branch 115 → 116 taken 16 times.
✗ Branch 118 → 119 not taken.
✓ Branch 118 → 137 taken 16 times.
16 if (!rebuild.exec(QStringLiteral(
948 "INSERT INTO mail_fts(mail_fts, rank) VALUES('crisismerge', 32)"))) {
949 ✗ qCWarning(lcCache) << "Failed to defer FTS5 crisis merge:"
950 ✗ << rebuild.lastError().text();
951 ✗ m_db.rollback();
952 ✗ restoreCacheSpill();
953 ✗ return false;
954 }
955
1/1
✓ Branch 140 → 141 taken 16 times.
16 if (!rebuild.exec(
956
1/2
✗ Branch 143 → 144 not taken.
✓ Branch 143 → 162 taken 16 times.
32 QStringLiteral("INSERT INTO mail_fts(mail_fts) VALUES('rebuild')"))) {
957 ✗ qCWarning(lcCache) << "FTS5 external-content rebuild failed:"
958 ✗ << rebuild.lastError().text();
959 ✗ m_db.rollback();
960 ✗ restoreCacheSpill();
961 ✗ return false;
962 }
963
2/3
✓ Branch 165 → 166 taken 16 times.
✗ Branch 168 → 169 not taken.
✓ Branch 168 → 187 taken 16 times.
16 if (!rebuild.exec(QStringLiteral(
964 "INSERT INTO mail_fts(mail_fts, rank) VALUES('automerge', 4)"))) {
965 ✗ qCWarning(lcCache) << "Failed to restore FTS5 automerge:"
966 ✗ << rebuild.lastError().text();
967 ✗ m_db.rollback();
968 ✗ restoreCacheSpill();
969 ✗ return false;
970 }
971
2/3
✓ Branch 190 → 191 taken 16 times.
✗ Branch 193 → 194 not taken.
✓ Branch 193 → 212 taken 16 times.
16 if (!rebuild.exec(QStringLiteral(
972 "INSERT INTO mail_fts(mail_fts, rank) VALUES('crisismerge', 16)"))) {
973 ✗ qCWarning(lcCache) << "Failed to restore FTS5 crisis merge:"
974 ✗ << rebuild.lastError().text();
975 ✗ m_db.rollback();
976 ✗ restoreCacheSpill();
977 ✗ return false;
978 }
979
1/1
✓ Branch 215 → 216 taken 16 times.
16 rebuild.prepare(QStringLiteral(
980 "INSERT OR REPLACE INTO cache_meta(key,value) "
981 "VALUES('fts_indexed_documents', :count)"));
982
1/1
✓ Branch 223 → 224 taken 16 times.
32 rebuild.bindValue(QStringLiteral(":count"), headerCount);
983 78 if (!rebuild.exec() ||
984
6/11
✓ Branch 232 → 233 taken 15 times.
✓ Branch 233 → 234 taken 15 times.
✗ Branch 233 → 236 not taken.
✓ Branch 240 → 241 taken 15 times.
✓ Branch 240 → 242 taken 1 time.
✓ Branch 242 → 243 taken 1 time.
✓ Branch 242 → 261 taken 15 times.
✗ Branch 429 → 430 not taken.
✗ Branch 429 → 431 not taken.
✗ Branch 433 → 434 not taken.
✗ Branch 433 → 435 not taken.
31 !rebuild.exec(QStringLiteral(
985
5/5
✓ Branch 227 → 228 taken 16 times.
✓ Branch 228 → 229 taken 15 times.
✓ Branch 228 → 236 taken 1 time.
✓ Branch 238 → 239 taken 15 times.
✓ Branch 238 → 240 taken 1 time.
31 "DELETE FROM cache_meta WHERE key = 'fts_rebuild_in_progress'")) ||
986
2/3
✓ Branch 234 → 235 taken 15 times.
✗ Branch 235 → 236 not taken.
✓ Branch 235 → 237 taken 15 times.
15 !m_db.commit()) {
987
5/5
✓ Branch 243 → 244 taken 1 time.
✓ Branch 247 → 248 taken 1 time.
✓ Branch 248 → 249 taken 1 time.
✓ Branch 257 → 245 taken 1 time.
✓ Branch 257 → 258 taken 1 time.
2 qCWarning(lcCache) << "Failed to commit completed FTS rebuild:"
988
3/3
✓ Branch 249 → 250 taken 1 time.
✓ Branch 250 → 251 taken 1 time.
✓ Branch 251 → 252 taken 1 time.
1 << m_db.lastError().text();
989
1/1
✓ Branch 258 → 259 taken 1 time.
1 m_db.rollback();
990
1/1
✓ Branch 259 → 260 taken 1 time.
1 restoreCacheSpill();
991 1 return false;
992 }
993
1/1
✓ Branch 261 → 262 taken 15 times.
15 restoreCacheSpill();
994
6/6
✓ Branch 262 → 263 taken 15 times.
✓ Branch 266 → 267 taken 15 times.
✓ Branch 267 → 268 taken 15 times.
✓ Branch 268 → 269 taken 15 times.
✓ Branch 274 → 264 taken 15 times.
✓ Branch 274 → 275 taken 15 times.
30 qCInfo(lcCache) << "FTS5 index rebuilt:" << headerCount
995
2/2
✓ Branch 269 → 270 taken 15 times.
✓ Branch 270 → 271 taken 15 times.
15 << "entries; repaired documents:" << repairedCount;
996 15 return true;
997 20 }
998