feat(search): Unicode-aware case-insensitive search via casefold columns
Restores Unicode-aware case-insensitive substring search (approach A of #227). Paging (#223) moved search to a SQL LIKE scan whose case-folding is ASCII-only, so non-ASCII terms stopped matching case-insensitively. Adds per-field casefold columns to `messages` (senderFold/senderEmailFold/ subjectFold/snippetFold), each = Kotlin lowercase() of its source (Unicode-aware). Per-field (not one concatenated column) because the fields are maintained by partial UPDATEs that don't carry all four: toEntity sets all folds, updateBody keeps snippetFold in sync, updateHeaderContent keeps the header folds -- via thin DAO default-method wrappers so the five call sites are unchanged. Search matches the fold columns with a pattern built from the lowercased query. Schema v18->v19 (additive; ASCII lower() backfill, non-ASCII rows re-fold on next write). Adds a MigrationTest v18->v19 case and a repo test asserting the query is casefolded. Closes #232 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 19,
|
||||
"identityHash": "eb8510d29c6bd5fdb450ab835b6a770e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, `senderFold` TEXT NOT NULL DEFAULT '', `senderEmailFold` TEXT NOT NULL DEFAULT '', `subjectFold` TEXT NOT NULL DEFAULT '', `snippetFold` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderFold",
|
||||
"columnName": "senderFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmailFold",
|
||||
"columnName": "senderEmailFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subjectFold",
|
||||
"columnName": "subjectFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippetFold",
|
||||
"columnName": "snippetFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentId",
|
||||
"columnName": "contentId",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb8510d29c6bd5fdb450ab835b6a770e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,32 @@ class MigrationTest {
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
/** v18 -> v19 (issue #232): the four casefold search columns appear, backfilled from their source. */
|
||||
@Test
|
||||
fun migrate18To19_addsAndBackfillsCasefoldSearchColumns() {
|
||||
helper.createDatabase(TEST_DB, 18).apply {
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada LOVELACE', 'Ada@Example.ORG', 'HELLO There', 'World Preview', " +
|
||||
"'', 0, 1000, 0, 0, 'INBOX', 1, 1, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 19, true, MIGRATION_18_19)
|
||||
|
||||
db.query(
|
||||
"SELECT senderFold, senderEmailFold, subjectFold, snippetFold FROM messages WHERE id = 'acct:INBOX:1'",
|
||||
).use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("ada lovelace", c.getString(0))
|
||||
assertEquals("ada@example.org", c.getString(1))
|
||||
assertEquals("hello there", c.getString(2))
|
||||
assertEquals("world preview", c.getString(3))
|
||||
}
|
||||
}
|
||||
|
||||
/** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */
|
||||
@Test
|
||||
fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() {
|
||||
|
||||
@@ -35,7 +35,7 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
FolderEntity::class,
|
||||
BackfillProgressEntity::class,
|
||||
],
|
||||
version = 18,
|
||||
version = 19,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class LibreMailDatabase : RoomDatabase() {
|
||||
|
||||
@@ -149,6 +149,10 @@ internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox:
|
||||
inInbox = inInbox,
|
||||
bodyFetched = false,
|
||||
uid = uid.toLongOrNull() ?: 0L,
|
||||
senderFold = sender.lowercase(),
|
||||
senderEmailFold = senderEmail.lowercase(),
|
||||
subjectFold = subject.lowercase(),
|
||||
snippetFold = "",
|
||||
)
|
||||
|
||||
internal fun FolderEntity.toDomain(): Folder = Folder(
|
||||
|
||||
@@ -359,3 +359,24 @@ val MIGRATION_17_18 = object : Migration(17, 18) {
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `attachments` TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v18 -> v19: Unicode-aware case-insensitive search (issue #232; preserves existing data). SQLite's
|
||||
* `LIKE` folds case only for ASCII, so search now matches against `lowercase()` copies of the four
|
||||
* searchable fields. Adds the fold columns and backfills them. The backfill's SQL `lower()` is
|
||||
* ASCII-only, so existing rows are casefolded for ASCII here and fully re-folded (via the Unicode-aware
|
||||
* Kotlin `lowercase()` in the mapper / DAO) on their next write or sync.
|
||||
*/
|
||||
val MIGRATION_18_19 = object : Migration(18, 19) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `senderFold` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `senderEmailFold` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `subjectFold` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `snippetFold` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL(
|
||||
"UPDATE `messages` SET `senderFold` = lower(`sender`), " +
|
||||
"`senderEmailFold` = lower(`senderEmail`), `subjectFold` = lower(`subject`), " +
|
||||
"`snippetFold` = lower(`snippet`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,15 +67,16 @@ interface MessageDao {
|
||||
* Scans the same columns the old in-memory search filter (`matchesSearch`) did, but in SQL so a
|
||||
* search over a large folder loads only the visible window. Unlike the browse pagers this does
|
||||
* *not* filter `inInbox`, so it surfaces both synced rows and the transient `inInbox = 0`
|
||||
* server-search hits `MailRepository.searchServer` inserts — exactly what the old filter saw. LIKE
|
||||
* is case-insensitive for ASCII (SQLite's default), matching `matchesSearch`'s common case.
|
||||
* server-search hits `MailRepository.searchServer` inserts — exactly what the old filter saw.
|
||||
* Matches the Unicode-casefolded `*Fold` columns (issue #232) with a pattern built from the
|
||||
* lowercased query, so search is case-insensitive beyond ASCII — unlike the old ASCII-only `LIKE`.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE folder = :folder AND (sender LIKE :pattern ESCAPE '\\' OR " +
|
||||
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
|
||||
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
|
||||
"WHERE folder = :folder AND (senderFold LIKE :pattern ESCAPE '\\' OR " +
|
||||
"senderEmailFold LIKE :pattern ESCAPE '\\' OR subjectFold LIKE :pattern ESCAPE '\\' OR " +
|
||||
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun pagingUnifiedFolderSearchSummaries(folder: String, pattern: String): PagingSource<Int, MessageSummary>
|
||||
|
||||
@@ -88,9 +89,9 @@ interface MessageDao {
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE accountId = :accountId AND folder = :folder AND (sender LIKE :pattern ESCAPE '\\' OR " +
|
||||
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
|
||||
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
|
||||
"WHERE accountId = :accountId AND folder = :folder AND (senderFold LIKE :pattern ESCAPE '\\' OR " +
|
||||
"senderEmailFold LIKE :pattern ESCAPE '\\' OR subjectFold LIKE :pattern ESCAPE '\\' OR " +
|
||||
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun pagingFolderSearchSummaries(
|
||||
accountId: String,
|
||||
@@ -157,12 +158,8 @@ interface MessageDao {
|
||||
* Refreshes the display fields (and the materialized [MessageEntity.uid], keeping it fresh for
|
||||
* rows migrated before the column existed) from the server without touching the cached body, the
|
||||
* local read/star flags (which may hold an optimistic change the server hasn't reflected yet), or
|
||||
* the inbox membership.
|
||||
* the inbox membership. Keeps the header casefold search columns (issue #232) in sync.
|
||||
*/
|
||||
@Query(
|
||||
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
|
||||
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
|
||||
)
|
||||
suspend fun updateHeaderContent(
|
||||
id: String,
|
||||
sender: String,
|
||||
@@ -170,14 +167,42 @@ interface MessageDao {
|
||||
subject: String,
|
||||
timestampMillis: Long,
|
||||
uid: Long,
|
||||
) = updateHeaderContentInternal(
|
||||
id, sender, senderEmail, subject,
|
||||
sender.lowercase(), senderEmail.lowercase(), subject.lowercase(),
|
||||
timestampMillis, uid,
|
||||
)
|
||||
|
||||
@Query(
|
||||
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
|
||||
"senderFold = :senderFold, senderEmailFold = :senderEmailFold, subjectFold = :subjectFold, " +
|
||||
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
|
||||
)
|
||||
suspend fun updateHeaderContentInternal(
|
||||
id: String,
|
||||
sender: String,
|
||||
senderEmail: String,
|
||||
subject: String,
|
||||
senderFold: String,
|
||||
senderEmailFold: String,
|
||||
subjectFold: String,
|
||||
timestampMillis: Long,
|
||||
uid: Long,
|
||||
)
|
||||
|
||||
/** Marks rows as folder-synced (e.g. a former search-only row that the sync now returns). */
|
||||
@Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)")
|
||||
suspend fun markSynced(ids: List<String>)
|
||||
|
||||
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id")
|
||||
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
|
||||
/** Sets the fetched body + its derived snippet, keeping [MessageEntity.snippetFold] (search) in sync. */
|
||||
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String) =
|
||||
updateBodyInternal(id, body, isHtml, snippet, snippet.lowercase())
|
||||
|
||||
@Query(
|
||||
"UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, " +
|
||||
"snippetFold = :snippetFold, bodyFetched = 1 WHERE id = :id",
|
||||
)
|
||||
suspend fun updateBodyInternal(id: String, body: String, isHtml: Boolean, snippet: String, snippetFold: String)
|
||||
|
||||
@Query("UPDATE messages SET isRead = :isRead WHERE id = :id")
|
||||
suspend fun setRead(id: String, isRead: Boolean)
|
||||
|
||||
@@ -38,4 +38,12 @@ data class MessageEntity(
|
||||
* (refreshed to the real UID on the next sync).
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0") val uid: Long = 0L,
|
||||
// Unicode-casefolded copies of the searchable fields (issue #232). SQLite's LIKE folds case only for
|
||||
// ASCII, so search matches against these `lowercase()` copies (Kotlin's lowercase is Unicode-aware)
|
||||
// for case-insensitive Unicode search. Kept in sync wherever their source is written — toEntity,
|
||||
// MessageDao.updateBody (snippet), and MessageDao.updateHeaderContent (headers).
|
||||
@ColumnInfo(defaultValue = "") val senderFold: String = "",
|
||||
@ColumnInfo(defaultValue = "") val senderEmailFold: String = "",
|
||||
@ColumnInfo(defaultValue = "") val subjectFold: String = "",
|
||||
@ColumnInfo(defaultValue = "") val snippetFold: String = "",
|
||||
)
|
||||
|
||||
@@ -106,14 +106,14 @@ class MailRepositoryImpl @Inject constructor(
|
||||
mailboxPager { messageDao.pagingFolderSummaries(accountId, folder) }
|
||||
|
||||
override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>> =
|
||||
mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query)) }
|
||||
mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query.lowercase())) }
|
||||
|
||||
override fun pagedFolderSearchMessages(
|
||||
accountId: String,
|
||||
folder: String,
|
||||
query: String,
|
||||
): Flow<PagingData<Message>> =
|
||||
mailboxPager { messageDao.pagingFolderSearchSummaries(accountId, folder, likePattern(query)) }
|
||||
mailboxPager { messageDao.pagingFolderSearchSummaries(accountId, folder, likePattern(query.lowercase())) }
|
||||
|
||||
/**
|
||||
* Shared [Pager] for the per-account and search mailbox lists (issue #214). Same window sizing as
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.libremail.data.local.MIGRATION_14_15
|
||||
import org.libremail.data.local.MIGRATION_15_16
|
||||
import org.libremail.data.local.MIGRATION_16_17
|
||||
import org.libremail.data.local.MIGRATION_17_18
|
||||
import org.libremail.data.local.MIGRATION_18_19
|
||||
import org.libremail.data.local.MIGRATION_1_2
|
||||
import org.libremail.data.local.MIGRATION_2_3
|
||||
import org.libremail.data.local.MIGRATION_3_4
|
||||
@@ -67,6 +68,7 @@ object DatabaseModule {
|
||||
MIGRATION_15_16,
|
||||
MIGRATION_16_17,
|
||||
MIGRATION_17_18,
|
||||
MIGRATION_18_19,
|
||||
)
|
||||
// No destructive fallback: the migration chain is complete, and silently dropping the
|
||||
// mail/message tables would lose cached data. A missing migration should fail loudly in
|
||||
|
||||
@@ -123,6 +123,19 @@ class MailRepositoryImplTest {
|
||||
assertEquals("%50\\%\\_off%", pattern.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagedUnifiedSearchMessages lowercases the query for Unicode case-insensitive matching`() = runTest {
|
||||
val pattern = slot<String>()
|
||||
every { messageDao.pagingUnifiedFolderSearchSummaries("INBOX", capture(pattern)) } returns
|
||||
FakeSummaryPagingSource(listOf(messageSummary("1", "INBOX")))
|
||||
|
||||
repository.pagedUnifiedSearchMessages("INBOX", "ÄPFEL").asSnapshot()
|
||||
|
||||
// The query is casefolded (Kotlin's Unicode-aware lowercase) before the LIKE pattern is built, so
|
||||
// it matches the lowercase()d *Fold columns regardless of case, beyond ASCII (issue #232).
|
||||
assertEquals("%äpfel%", pattern.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagedFolderSearchMessages scopes the paged search to the account and folder`() = runTest {
|
||||
val pattern = slot<String>()
|
||||
|
||||
Reference in New Issue
Block a user