Signal-iOS/SignalServiceKit/src/Storage/FullTextSearchFinder.swift
2023-06-14 21:34:12 -04:00

754 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
public enum FullTextSearchFinder {
public static let matchTag = "match"
public static func enumerateObjects(searchText: String, collections: [String], maxResults: UInt, transaction: SDSAnyReadTransaction, block: (Any, String, inout Bool) -> Void) {
switch transaction.readTransaction {
case .grdbRead(let grdbRead):
GRDBFullTextSearchFinder.enumerateObjects(searchText: searchText, collections: collections, maxResults: maxResults, transaction: grdbRead, block: block)
}
}
public static func enumerateObjects<T: SDSIndexableModel>(searchText: String, maxResults: UInt, transaction: SDSAnyReadTransaction, block: (T, String, inout Bool) -> Void) {
switch transaction.readTransaction {
case .grdbRead(let grdbRead):
GRDBFullTextSearchFinder.enumerateObjects(searchText: searchText, maxResults: maxResults, transaction: grdbRead, block: block)
}
}
public static func modelWasInserted(model: SDSIndexableModel, transaction: SDSAnyWriteTransaction) {
assert(type(of: model).ftsIndexMode != .never)
switch transaction.writeTransaction {
case .grdbWrite(let grdbWrite):
GRDBFullTextSearchFinder.modelWasInserted(model: model, transaction: grdbWrite)
}
}
public static func modelWasUpdated(model: SDSIndexableModel, transaction: SDSAnyWriteTransaction) {
assert(type(of: model).ftsIndexMode != .never)
switch transaction.writeTransaction {
case .grdbWrite(let grdbWrite):
GRDBFullTextSearchFinder.modelWasUpdated(model: model, transaction: grdbWrite)
}
}
public static func modelWasInsertedOrUpdated(model: SDSIndexableModel, transaction: SDSAnyWriteTransaction) {
assert(type(of: model).ftsIndexMode != .never)
switch transaction.writeTransaction {
case .grdbWrite(let grdbWrite):
GRDBFullTextSearchFinder.modelWasInsertedOrUpdated(model: model, transaction: grdbWrite)
}
}
public static func modelWasRemoved(model: SDSIndexableModel, transaction: SDSAnyWriteTransaction) {
assert(type(of: model).ftsIndexMode != .never)
switch transaction.writeTransaction {
case .grdbWrite(let grdbWrite):
GRDBFullTextSearchFinder.modelWasRemoved(model: model, transaction: grdbWrite)
}
}
public static func allModelsWereRemoved(collection: String, transaction: SDSAnyWriteTransaction) {
switch transaction.writeTransaction {
case .grdbWrite(let grdbWrite):
GRDBFullTextSearchFinder.allModelsWereRemoved(collection: collection, transaction: grdbWrite)
}
}
}
// MARK: - Normalization
extension FullTextSearchFinder {
fileprivate static var charactersToRemove: CharacterSet = {
// * We want to strip punctuation - and our definition of "punctuation"
// is broader than `CharacterSet.punctuationCharacters`.
// * FTS should be robust to (i.e. ignore) illegal and control characters,
// but it's safer if we filter them ourselves as well.
var charactersToFilter = CharacterSet.punctuationCharacters
charactersToFilter.formUnion(CharacterSet.illegalCharacters)
charactersToFilter.formUnion(CharacterSet.controlCharacters)
// We want to strip all ASCII characters except:
// * Letters a-z, A-Z
// * Numerals 0-9
// * Whitespace
var asciiToFilter = CharacterSet(charactersIn: UnicodeScalar(0x0)!..<UnicodeScalar(0x80)!)
assert(!asciiToFilter.contains(UnicodeScalar(0x80)!))
asciiToFilter.subtract(CharacterSet.alphanumerics)
asciiToFilter.subtract(CharacterSet.whitespacesAndNewlines)
charactersToFilter.formUnion(asciiToFilter)
return charactersToFilter
}()
// This is a hot method, especially while running large migrations.
// Changes to it should go through a profiler to make sure large migrations
// aren't adversely affected.
public static func normalize(text: String) -> String {
// 1. Filter out invalid characters.
let filtered = text.removeCharacters(characterSet: charactersToRemove)
// 2. Simplify whitespace.
let simplified = filtered.replaceCharacters(characterSet: .whitespacesAndNewlines,
replacement: " ")
// 3. Strip leading & trailing whitespace last, since we may replace
// filtered characters with whitespace.
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
// 4. Use canonical mapping.
//
// From the GRDB docs:
//
// Generally speaking, matches may fail when content and query dont use
// the same unicode normalization. SQLite actually exhibits inconsistent
// behavior in this regard.
//
// For example, for aimé to match aimé, they better have the same
// normalization: the NFC aim\u{00E9} form may not match its NFD aime\u{0301}
// equivalent. Most strings that you get from Swift, UIKit and Cocoa use NFC,
// so be careful with NFD inputs (such as strings from the HFS+ file system,
// or strings that you cant trust like network inputs). Use
// String.precomposedStringWithCanonicalMapping to turn a string into NFC.
//
// Besides, if you want fi to match the ligature (U+FB01), then you need
// to normalize your indexed contents and inputs to NFKC or NFKD. Use
// String.precomposedStringWithCompatibilityMapping to turn a string into NFKC.
let canonical = trimmed.precomposedStringWithCanonicalMapping
return canonical
}
}
// MARK: - Querying
// We use SQLite's FTS5 for GRDB.
extension FullTextSearchFinder {
// We want to match by prefix for "search as you type" functionality.
// SQLite does not support suffix or contains matches.
public static func query(searchText: String) -> String {
// 1. Normalize the search text.
//
// TODO: We could arguably convert to lowercase since the search
// is case-insensitive.
let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText)
// 2. Split the non-numeric text into query terms (or tokens).
let nonNumericText = String(String.UnicodeScalarView(normalizedSearchText.unicodeScalars.lazy.map {
if CharacterSet.decimalDigits.contains($0) {
return " "
} else {
return $0
}
}))
var queryTerms = nonNumericText.split(separator: " ")
// 3. Add an additional numeric-only query term.
let digitsOnlyScalars = normalizedSearchText.unicodeScalars.lazy.filter {
CharacterSet.decimalDigits.contains($0)
}
let digitsOnly: Substring = Substring(String(String.UnicodeScalarView(digitsOnlyScalars)))
queryTerms.append(digitsOnly)
// 4. De-duplicate and sort query terms.
// Duplicate terms are redundant.
// Sorting terms makes the output of this method deterministic and easier to test,
// and the order won't affect the search results.
queryTerms = Array(Set(queryTerms)).sorted()
// 5. Filter the query terms.
let filteredQueryTerms = queryTerms.filter {
// Ignore empty terms.
$0.count > 0
}.map {
// Allow partial match of each term.
//
// Note that we use double-quotes to enclose each search term.
// Quoted search terms can include a few more characters than
// "bareword" (non-quoted) search terms. This shouldn't matter,
// since we're filtering all of the affected characters, but
// quoting protects us from any bugs in that logic.
"\"\($0)\"*"
}
// 6. Join terms into query string.
let query = filteredQueryTerms.joined(separator: " ")
return query
}
}
// MARK: -
// See: http://groue.github.io/GRDB.swift/docs/4.1/index.html#full-text-search
// See: https://www.sqlite.org/fts5.html
enum GRDBFullTextSearchFinder {
static let contentTableName = "indexable_text"
static let ftsTableName = "indexable_text_fts"
static let uniqueIdColumn = "uniqueId"
static let collectionColumn = "collection"
static let ftsContentColumn = "ftsIndexableContent"
static var matchTag: String { FullTextSearchFinder.matchTag }
public static let indexableModelTypes: [SDSIndexableModel.Type] = [
TSThread.self,
TSInteraction.self,
TSGroupMember.self,
SignalAccount.self,
SignalRecipient.self
]
private static func collection(forModel model: SDSIndexableModel) -> String {
// Note that allModelsWereRemoved(collection: ) makes the same
// assumption that the FTS collection matches the
// TSYapDatabaseObject.collection.
return type(of: model).collection()
}
private static let serialQueue = DispatchQueue(label: "org.signal.fts")
// This should only be accessed on serialQueue.
private static let ftsCache = LRUCache<String, String>(maxSize: 128, nseMaxSize: 16)
private static func cacheKey(collection: String, uniqueId: String) -> String {
return "\(collection).\(uniqueId)"
}
#if TESTABLE_BUILD
private static func `is`(_ value: Any, ofType type: Any.Type) -> Bool {
var currentMirror: Mirror? = Mirror(reflecting: value)
while let mirror = currentMirror {
if mirror.subjectType == type { return true }
currentMirror = mirror.superclassMirror
}
return false
}
#endif
fileprivate static func shouldIndexModel(_ model: SDSIndexableModel) -> Bool {
#if TESTABLE_BUILD
let isIndexable = indexableModelTypes.contains { Self.is(model, ofType: $0) }
owsAssert(isIndexable)
#endif
if let userProfile = model as? OWSUserProfile,
OWSUserProfile.isLocalProfileAddress(userProfile.address) {
// We don't need to index the user profile for the local user.
return false
}
if let signalAccount = model as? SignalAccount,
OWSUserProfile.isLocalProfileAddress(signalAccount.recipientAddress) {
// We don't need to index the signal account for the local user.
return false
}
if let signalRecipient = model as? SignalRecipient,
OWSUserProfile.isLocalProfileAddress(signalRecipient.address) {
// We don't need to index the signal recipient for the local user.
return false
}
if let contactThread = model as? TSContactThread,
contactThread.contactPhoneNumber == kLocalProfileInvariantPhoneNumber {
// We don't need to index the contact thread for the "local invariant phone number".
// We do want to index the contact thread for the local user; that will have a
// different address.
return false
}
if
let message = model as? TSMessage,
(message.isGroupStoryReply || message.editState == .pastRevision)
{
return false
}
return true
}
public static func modelWasInserted(model: SDSIndexableModel, transaction: GRDBWriteTransaction) {
guard shouldIndexModel(model) else {
Logger.verbose("Not indexing model: \(type(of: (model)))")
removeModelFromIndex(model, transaction: transaction)
return
}
let uniqueId = model.uniqueId
let collection = self.collection(forModel: model)
let ftsContent = AnySearchIndexer.indexContent(object: model, transaction: transaction.asAnyRead) ?? ""
serialQueue.sync {
let cacheKey = self.cacheKey(collection: collection, uniqueId: uniqueId)
ftsCache.setObject(ftsContent, forKey: cacheKey)
}
executeUpdate(
sql: """
INSERT INTO \(contentTableName)
(\(collectionColumn), \(uniqueIdColumn), \(ftsContentColumn))
VALUES
(?, ?, ?)
""",
arguments: [collection, uniqueId, ftsContent],
transaction: transaction)
}
public static func modelWasUpdated(model: SDSIndexableModel, transaction: GRDBWriteTransaction) {
guard shouldIndexModel(model) else {
Logger.verbose("Not indexing model: \(type(of: (model)))")
removeModelFromIndex(model, transaction: transaction)
return
}
let uniqueId = model.uniqueId
let collection = self.collection(forModel: model)
let ftsContent = AnySearchIndexer.indexContent(object: model, transaction: transaction.asAnyRead) ?? ""
let shouldSkipUpdate: Bool = serialQueue.sync {
guard !CurrentAppContext().isRunningTests else {
return false
}
let cacheKey = self.cacheKey(collection: collection, uniqueId: uniqueId)
if let cachedValue = ftsCache.object(forKey: cacheKey),
(cachedValue as String) == ftsContent {
return true
}
ftsCache.setObject(ftsContent, forKey: cacheKey)
return false
}
guard !shouldSkipUpdate else {
if !DebugFlags.reduceLogChatter {
Logger.verbose("Skipping FTS update")
}
return
}
executeUpdate(
sql: """
UPDATE \(contentTableName)
SET \(ftsContentColumn) = ?
WHERE \(collectionColumn) == ?
AND \(uniqueIdColumn) == ?
""",
arguments: [ftsContent, collection, uniqueId],
transaction: transaction)
}
public static func modelWasInsertedOrUpdated(model: SDSIndexableModel, transaction: GRDBWriteTransaction) {
guard shouldIndexModel(model) else {
Logger.verbose("Not indexing model: \(type(of: (model)))")
removeModelFromIndex(model, transaction: transaction)
return
}
let uniqueId = model.uniqueId
let collection = self.collection(forModel: model)
let ftsContent = AnySearchIndexer.indexContent(object: model, transaction: transaction.asAnyRead) ?? ""
let shouldSkipUpdate: Bool = serialQueue.sync {
guard !CurrentAppContext().isRunningTests else {
return false
}
let cacheKey = self.cacheKey(collection: collection, uniqueId: uniqueId)
if let cachedValue = ftsCache.object(forKey: cacheKey),
(cachedValue as String) == ftsContent {
return true
}
ftsCache.setObject(ftsContent, forKey: cacheKey)
return false
}
guard !shouldSkipUpdate else {
if !DebugFlags.reduceLogChatter {
Logger.verbose("Skipping FTS update")
}
return
}
executeUpdate(
// See: https://www.sqlite.org/lang_UPSERT.html
sql: """
INSERT INTO \(contentTableName) (
\(collectionColumn),
\(uniqueIdColumn),
\(ftsContentColumn)
) VALUES (?, ?, ?)
ON CONFLICT (
\(collectionColumn),
\(uniqueIdColumn)
) DO UPDATE
SET \(ftsContentColumn) = ?
""",
arguments: [collection, uniqueId, ftsContent, ftsContent],
transaction: transaction)
}
public static func modelWasRemoved(model: SDSIndexableModel, transaction: GRDBWriteTransaction) {
removeModelFromIndex(model, transaction: transaction)
}
private static func removeModelFromIndex(_ model: SDSIndexableModel, transaction: GRDBWriteTransaction) {
let uniqueId = model.uniqueId
let collection = self.collection(forModel: model)
serialQueue.sync {
let cacheKey = self.cacheKey(collection: collection, uniqueId: uniqueId)
ftsCache.removeObject(forKey: cacheKey)
}
executeUpdate(
sql: """
DELETE FROM \(contentTableName)
WHERE \(uniqueIdColumn) == ?
AND \(collectionColumn) == ?
""",
arguments: [uniqueId, collection],
transaction: transaction)
}
public static func allModelsWereRemoved(collection: String, transaction: GRDBWriteTransaction) {
serialQueue.sync {
ftsCache.removeAllObjects()
}
executeUpdate(
sql: """
DELETE FROM \(contentTableName)
WHERE \(collectionColumn) == ?
""",
arguments: [collection],
transaction: transaction)
}
private static let disableFTS = false
private static func executeUpdate(
sql: String,
arguments: StatementArguments,
transaction: GRDBWriteTransaction
) {
guard !disableFTS else {
return
}
transaction.executeAndCacheStatement(
sql: sql,
arguments: arguments
)
}
private static func modelForFTSMatch(
collection: String,
uniqueId: String,
transaction: GRDBReadTransaction
) -> SDSIndexableModel? {
switch collection {
case SignalAccount.collection():
guard let model = SignalAccount.anyFetch(uniqueId: uniqueId,
transaction: transaction.asAnyRead) else {
owsFailDebug("Couldn't load record: \(collection)")
return nil
}
return model
case TSThread.collection():
guard let model = TSThread.anyFetch(uniqueId: uniqueId,
transaction: transaction.asAnyRead) else {
owsFailDebug("Couldn't load record: \(collection)")
return nil
}
return model
case TSInteraction.collection():
guard let model = TSInteraction.anyFetch(uniqueId: uniqueId,
transaction: transaction.asAnyRead) else {
owsFailDebug("Couldn't load record: \(collection)")
return nil
}
return model
case SignalRecipient.collection():
guard let model = SignalRecipient.anyFetch(uniqueId: uniqueId,
transaction: transaction.asAnyRead) else {
owsFailDebug("Couldn't load record: \(collection)")
return nil
}
return model
case TSGroupMember.collection():
guard let model = TSGroupMember.anyFetch(uniqueId: uniqueId, transaction: transaction.asAnyRead) else {
owsFailDebug("Couldn't load record: \(collection)")
return nil
}
return model
default:
owsFailDebug("Unexpected record type: \(collection)")
return nil
}
}
// MARK: - Querying
public static func enumerateObjects<T: SDSIndexableModel>(
searchText: String,
maxResults: UInt,
transaction: GRDBReadTransaction,
block: (T, String, inout Bool) -> Void
) {
enumerateObjects(
searchText: searchText,
collections: [T.collection()],
maxResults: maxResults,
transaction: transaction
) { object, snippet, stop in
guard nil == object as? OWSGroupCallMessage else {
return
}
guard let object = object as? T else {
return owsFailDebug("Unexpected object type")
}
block(object, snippet, &stop)
}
}
public static func enumerateObjects(
searchText: String,
collections: [String],
maxResults: UInt,
transaction: GRDBReadTransaction,
block: (Any, String, inout Bool) -> Void
) {
let query = FullTextSearchFinder.query(searchText: searchText)
if query.isEmpty {
// FullTextSearchFinder.query filters some characters, so query
// may now be empty.
Logger.warn("Empty query.")
return
}
// Search with the query interface or SQL
do {
var stop = false
// GRDB TODO: We could use bm25() instead of rank to order results.
let indexOfContentColumnInFTSTable = 0
// Determines the length of the snippet.
let numTokens: UInt = 15
let matchSnippet = "match_snippet"
let sql: String = """
SELECT
\(contentTableName).\(collectionColumn),
\(contentTableName).\(uniqueIdColumn),
snippet(\(ftsTableName), \(indexOfContentColumnInFTSTable), '<\(matchTag)>', '</\(matchTag)>', '…', \(numTokens) ) as \(matchSnippet)
FROM \(ftsTableName)
LEFT JOIN \(contentTableName) ON \(contentTableName).rowId = \(ftsTableName).rowId
WHERE \(ftsTableName).\(ftsContentColumn) MATCH ?
AND \(collectionColumn) IN (\(collections.map { "'\($0)'" }.joined(separator: ",")))
ORDER BY rank
LIMIT \(maxResults)
"""
let cursor = try Row.fetchCursor(transaction.database, sql: sql, arguments: [query])
while let row = try cursor.next() {
let collection: String = row[collectionColumn]
let uniqueId: String = row[uniqueIdColumn]
let snippet: String = row[matchSnippet]
guard !collection.isEmpty, !uniqueId.isEmpty else {
owsFailDebug("Invalid match: collection: \(collection), uniqueId: \(uniqueId).")
continue
}
guard let model = modelForFTSMatch(collection: collection,
uniqueId: uniqueId,
transaction: transaction) else {
owsFailDebug("Missing model for search result.")
continue
}
block(model, snippet, &stop)
if stop {
break
}
}
} catch {
owsFailDebug("Couldn't fetch results: \(error)")
}
}
}
// MARK: -
// Create a searchable index for objects of type T
class SearchIndexer<T> {
private let indexBlock: (T, SDSAnyReadTransaction) -> String
public init(indexBlock: @escaping (T, SDSAnyReadTransaction) -> String) {
self.indexBlock = indexBlock
}
public func index(_ item: T, transaction: SDSAnyReadTransaction) -> String {
return normalize(indexingText: indexBlock(item, transaction))
}
private func normalize(indexingText: String) -> String {
return FullTextSearchFinder.normalize(text: indexingText)
}
}
// MARK: -
class AnySearchIndexer: Dependencies {
// MARK: - Index Building
private static let groupThreadIndexer: SearchIndexer<TSGroupThread> = SearchIndexer { (groupThread: TSGroupThread, _: SDSAnyReadTransaction) in
return groupThread.groupModel.groupNameOrDefault
}
private static let privateStoryThreadIndexer: SearchIndexer<TSPrivateStoryThread> = SearchIndexer { (storyThread: TSPrivateStoryThread, _: SDSAnyReadTransaction) in
// NOTE: returns the right name for my story as well.
return storyThread.name
}
private static let groupMemberIndexer: SearchIndexer<TSGroupMember> = SearchIndexer { (groupMember: TSGroupMember, transaction: SDSAnyReadTransaction) in
return recipientIndexer.index(
SignalServiceAddress(uuid: groupMember.serviceId?.uuidValue, phoneNumber: groupMember.phoneNumber),
transaction: transaction
)
}
private static let contactThreadIndexer: SearchIndexer<TSContactThread> = SearchIndexer { (contactThread: TSContactThread, transaction: SDSAnyReadTransaction) in
let recipientAddress = contactThread.contactAddress
var result = recipientIndexer.index(recipientAddress, transaction: transaction)
if contactThread.isNoteToSelf {
let noteToSelfLabel = OWSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.")
result += " \(noteToSelfLabel)"
}
return result
}
private static let accountIndexer: SearchIndexer<SignalAccount> = SearchIndexer { account, tx in
var nameStrings = Set<String>()
let insert: (String?) -> Void = { if let s = $0 { nameStrings.insert(s) } }
// In the UI, we give preference to the system contact name
// (as opposed to the contact's self-selected profile name),
// so our first choice is to index by system contact names.
insert(account.contactPreferredDisplayName())
if let nameComponents = account.contactPersonNameComponents() {
insert(nameComponents.givenName)
insert(nameComponents.familyName)
insert(nameComponents.nickname)
}
if nameStrings.isEmpty {
// If the system contact has no names, fall back to
// the user's self-selected profile name.
insert(profileManager.fullName(for: account.recipientAddress, transaction: tx))
}
return contactIndexStrings(nameStrings: nameStrings, recipientAddress: account.recipientAddress, transaction: tx)
}
private static let recipientIndexer: SearchIndexer<SignalServiceAddress> = SearchIndexer { recipientAddress, tx in
// A contact should always be searchable by their display name, as well
// as by name components from system contacts if available. Note that
// not all name components are available, as we only store
// given/family/nicknames (excludes middle/prefix/suffix/phonetic).
//
// We may likely end up with duplicate text in the index since the
// display name will likely include some or all of the name components,
// but that's fine.
// TODO: `displayName` is a performance bottleneck. Its slowness is ultimately
// due to fetching SignalAccounts from the SignalAccountReadCache. The role of
// read caches in the app is being revisited and may solve this problem.
var nameStrings: Set<String> = [contactsManager.displayName(for: recipientAddress, transaction: tx)]
if let nameComponents = contactsManager.nameComponents(for: recipientAddress, transaction: tx) {
let insert: (String?) -> Void = { if let s = $0 { nameStrings.insert(s) } }
insert(nameComponents.givenName)
insert(nameComponents.familyName)
insert(nameComponents.nickname)
}
return contactIndexStrings(nameStrings: nameStrings, recipientAddress: recipientAddress, transaction: tx)
}
private static func contactIndexStrings(nameStrings: Set<String>, recipientAddress: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
let nationalNumber: String? = { (recipientId: String?) -> String? in
guard let recipientId = recipientId else { return nil }
guard recipientId != kLocalProfileInvariantPhoneNumber else {
return ""
}
guard let phoneNumber = PhoneNumber(fromE164: recipientId) else {
owsFailDebug("unexpected unparsable recipientId: \(recipientId)")
return ""
}
guard let digitScalars = phoneNumber.nationalNumberFormatted?.unicodeScalars.filter({ CharacterSet.decimalDigits.contains($0) }) else {
owsFailDebug("unexpected unparsable recipientId: \(recipientId)")
return ""
}
return String(String.UnicodeScalarView(digitScalars))
}(recipientAddress.phoneNumber)
return "\(recipientAddress.phoneNumber ?? "") \(nationalNumber ?? "") \(nameStrings.joined(separator: " "))"
}
private static let messageIndexer: SearchIndexer<TSMessage> = SearchIndexer { (message: TSMessage, transaction: SDSAnyReadTransaction) in
if let bodyText = message.rawBody(with: transaction.unwrapGrdbRead) {
return bodyText
}
return ""
}
class func indexContent(object: SDSIndexableModel, transaction: SDSAnyReadTransaction) -> String? {
owsAssertDebug(GRDBFullTextSearchFinder.shouldIndexModel(object))
if let groupThread = object as? TSGroupThread {
return self.groupThreadIndexer.index(groupThread, transaction: transaction)
} else if let privateStoryThread = object as? TSPrivateStoryThread {
return self.privateStoryThreadIndexer.index(privateStoryThread, transaction: transaction)
} else if let groupMember = object as? TSGroupMember {
return self.groupMemberIndexer.index(groupMember, transaction: transaction)
} else if let contactThread = object as? TSContactThread {
guard contactThread.shouldThreadBeVisible else {
// If we've never sent/received a message in a TSContactThread,
// then we want it to appear in the "Other Contacts" section rather
// than in the "Conversations" section.
return nil
}
return self.contactThreadIndexer.index(contactThread, transaction: transaction)
} else if let message = object as? TSMessage {
guard !message.isViewOnceMessage else {
// Don't index "view-once messages".
return nil
}
return self.messageIndexer.index(message, transaction: transaction)
} else if let signalAccount = object as? SignalAccount {
return self.accountIndexer.index(signalAccount, transaction: transaction)
} else if let signalRecipient = object as? SignalRecipient {
return self.recipientIndexer.index(signalRecipient.address, transaction: transaction)
} else {
// This should be impossible (see assertion above), but we have it here just in case.
return nil
}
}
}
public protocol SDSIndexableModel {
var uniqueId: String { get }
static var ftsIndexMode: TSFTSIndexMode { get }
static func collection() -> String
static func anyEnumerateIndexable(
transaction: SDSAnyReadTransaction,
block: @escaping (SDSIndexableModel) -> Void
)
}