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(" "), QStringLiteral(" ")); |
| 48 |
1/1✓ Branch 56 → 57 taken 62 times.
|
124 | s.replace(QStringLiteral("&"), QStringLiteral("&")); |
| 49 |
1/1✓ Branch 67 → 68 taken 62 times.
|
124 | s.replace(QStringLiteral("<"), QStringLiteral("<")); |
| 50 |
1/1✓ Branch 78 → 79 taken 62 times.
|
124 | s.replace(QStringLiteral(">"), QStringLiteral(">")); |
| 51 |
1/1✓ Branch 89 → 90 taken 62 times.
|
124 | s.replace(QStringLiteral("""), QStringLiteral("\"")); |
| 52 |
1/1✓ Branch 100 → 101 taken 62 times.
|
124 | s.replace(QStringLiteral("'"), QStringLiteral("'")); |
| 53 | // Numeric entities (ä / ä) → 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 |