/// The virtual table module for the FTS4 full-text engine. /// /// To create FTS4 tables, use the ``Database`` method /// ``Database/create(virtualTable:ifNotExists:using:_:)``: /// /// ```swift /// // CREATE VIRTUAL TABLE document USING fts4(content) /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("content") /// } /// ``` /// /// Related SQLite documentation: /// /// ## Topics /// /// ### The FTS4 Module /// /// - ``init()`` /// - ``FTS4TableDefinition`` /// - ``FTS4ColumnDefinition`` public struct FTS4 { /// Creates an FTS4 module. /// /// For example: /// /// ```swift /// // CREATE VIRTUAL TABLE document USING fts4(content) /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("content") /// } /// ``` /// /// See ``Database/create(virtualTable:ifNotExists:using:_:)`` public init() { } } extension FTS4: VirtualTableModule { public var moduleName: String { "fts4" } public func makeTableDefinition(configuration: VirtualTableConfiguration) -> FTS4TableDefinition { FTS4TableDefinition(configuration: configuration) } public func moduleArguments(for definition: FTS4TableDefinition, in db: Database) -> [String] { var arguments: [String] = [] for column in definition.columns { if column.isLanguageId { arguments.append("languageid=\"\(column.name)\"") } else { arguments.append(column.name) if !column.isIndexed { arguments.append("notindexed=\(column.name)") } } } if let tokenizer = definition.tokenizer { if tokenizer.arguments.isEmpty { arguments.append("tokenize=\(tokenizer.name)") } else { arguments.append( "tokenize=\(tokenizer.name) " + tokenizer.arguments .map { "\"\($0)\"" as String } .joined(separator: " ")) } } switch definition.contentMode { case .raw(let content): if let content { arguments.append("content=\"\(content)\"") } case .synchronized(let contentTable): arguments.append("content=\"\(contentTable)\"") } if let compress = definition.compress { arguments.append("compress=\"\(compress)\"") } if let uncompress = definition.uncompress { arguments.append("uncompress=\"\(uncompress)\"") } if let matchinfo = definition.matchinfo { arguments.append("matchinfo=\"\(matchinfo)\"") } if let prefixes = definition.prefixes { arguments.append("prefix=\"\(prefixes.sorted().map { "\($0)" }.joined(separator: ","))\"") } return arguments } public func database(_ db: Database, didCreate tableName: String, using definition: FTS4TableDefinition) throws { switch definition.contentMode { case .raw: break case .synchronized(let contentTable): // https://www.sqlite.org/fts3.html#_external_content_fts4_tables_ let rowIDColumn = try db.primaryKey(contentTable).rowIDColumn ?? Column.rowID.name let ftsTable = tableName.quotedDatabaseIdentifier let content = contentTable.quotedDatabaseIdentifier let indexedColumns = definition.columns.map(\.name) let ftsColumns = (["docid"] + indexedColumns) .map(\.quotedDatabaseIdentifier) .joined(separator: ", ") let newContentColumns = ([rowIDColumn] + indexedColumns) .map { "new.\($0.quotedDatabaseIdentifier)" } .joined(separator: ", ") let oldRowID = "old.\(rowIDColumn.quotedDatabaseIdentifier)" let ifNotExists = definition.configuration.ifNotExists ? "IF NOT EXISTS " : "" // swiftlint:disable line_length try db.execute(sql: """ CREATE TRIGGER \(ifNotExists)\("__\(tableName)_bu".quotedDatabaseIdentifier) BEFORE UPDATE ON \(content) BEGIN DELETE FROM \(ftsTable) WHERE docid=\(oldRowID); END; CREATE TRIGGER \(ifNotExists)\("__\(tableName)_bd".quotedDatabaseIdentifier) BEFORE DELETE ON \(content) BEGIN DELETE FROM \(ftsTable) WHERE docid=\(oldRowID); END; CREATE TRIGGER \(ifNotExists)\("__\(tableName)_au".quotedDatabaseIdentifier) AFTER UPDATE ON \(content) BEGIN INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES(\(newContentColumns)); END; CREATE TRIGGER \(ifNotExists)\("__\(tableName)_ai".quotedDatabaseIdentifier) AFTER INSERT ON \(content) BEGIN INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES(\(newContentColumns)); END; """) // swiftlint:enable line_length // https://www.sqlite.org/fts3.html#*fts4rebuidcmd try db.execute(sql: "INSERT INTO \(ftsTable)(\(ftsTable)) VALUES('rebuild')") } } } /// A `FTS4TableDefinition` lets you define the components of an FTS4 /// virtual table. /// /// You don't create instances of this class. Instead, you use the `Database` /// ``Database/create(virtualTable:ifNotExists:using:_:)`` method: /// /// ```swift /// try db.create(virtualTable: "document", using: FTS4()) { t in // t is FTS4TableDefinition /// t.column("content") /// } /// ``` /// /// ## Topics /// /// ### Define Columns /// /// - ``column(_:)`` /// /// ### External Content Tables /// /// - ``synchronize(withTable:)`` /// /// ### FTS4 Options /// /// - ``compress`` /// - ``content`` /// - ``matchinfo`` /// - ``prefixes`` /// - ``tokenizer`` /// - ``uncompress`` public final class FTS4TableDefinition { enum ContentMode { case raw(content: String?) case synchronized(contentTable: String) } fileprivate let configuration: VirtualTableConfiguration fileprivate var columns: [FTS4ColumnDefinition] = [] fileprivate var contentMode: ContentMode = .raw(content: nil) /// The virtual table tokenizer. /// /// For example: /// /// ```swift /// // CREATE VIRTUAL TABLE documents USING fts4(tokenize=porter) /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.tokenizer = .porter /// } /// ``` /// /// Related SQLite documentation: public var tokenizer: FTS3TokenizerDescriptor? /// The FTS4 `content` option. /// /// When you want the full-text table to be synchronized with the /// content of an external table, prefer the /// ``synchronize(withTable:)`` method. /// /// Setting this property invalidates any synchronization previously /// established with the ``synchronize(withTable:)`` method. /// /// Related SQLite documentation: public var content: String? { get { switch contentMode { case .raw(let content): return content case .synchronized(let contentTable): return contentTable } } set { contentMode = .raw(content: newValue) } } /// The FTS4 `compress` option. /// /// Related SQLite documentation: public var compress: String? /// The FTS4 `uncompress` option. /// /// Related SQLite documentation: public var uncompress: String? /// The FTS4 `matchinfo` option. /// /// Related SQLite documentation: public var matchinfo: String? /// The FTS4 `prefix` option. /// /// // CREATE VIRTUAL TABLE document USING FTS4(content, prefix='2 4'); /// try db.create(virtualTable: "document", using:FTS4()) { t in /// t.prefixes = [2, 4] /// t.column("content") /// } /// /// Related SQLite documentation: public var prefixes: Set? init(configuration: VirtualTableConfiguration) { self.configuration = configuration } /// Appends a table column. /// /// For example: /// /// ```swift /// // CREATE VIRTUAL TABLE document USING fts4(content) /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("content") /// } /// ``` /// /// - parameter name: the column name. /// - returns: A ``FTS4ColumnDefinition`` that allows you to refine the /// column definition. @discardableResult public func column(_ name: String) -> FTS4ColumnDefinition { let column = FTS4ColumnDefinition(name: name) columns.append(column) return column } /// Synchronizes the full-text table with the content of an external /// table. /// /// The full-text table is initially populated with the existing /// content in the external table. SQL triggers make sure that the /// full-text table is kept up to date with the external table. /// /// SQLite automatically deletes those triggers when the content /// (not full-text) table is dropped. /// /// However, those triggers remain after the full-text table has been /// dropped. Unless they are dropped too, they will prevent future /// insertion, updates, and deletions in the content table, and the creation /// of a new full-text table. /// /// To drop those triggers, call the `Database` /// ``Database/dropFTS4SynchronizationTriggers(forTable:)`` method: /// /// ```swift /// // Create tables /// try db.create(table: "book") { t in /// ... /// } /// try db.create(virtualTable: "book_ft", using: FTS4()) { t in /// t.synchronize(withTable: "book") /// ... /// } /// /// // Drop full-text table /// try db.drop(table: "book_ft") /// try db.dropFTS4SynchronizationTriggers(forTable: "book_ft") /// ``` /// /// Related SQLite documentation: public func synchronize(withTable tableName: String) { contentMode = .synchronized(contentTable: tableName) } } // Explicit non-conformance to Sendable: `FTS4TableDefinition` is a mutable // class and there is no known reason for making it thread-safe. @available(*, unavailable) extension FTS4TableDefinition: Sendable { } /// Describes a column in an ``FTS4`` virtual table. /// /// You get instances of `FTS4ColumnDefinition` when you create an ``FTS4`` /// virtual table. For example: /// /// ```swift /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("content") // FTS4ColumnDefinition /// } /// ``` /// /// Related SQLite documentation: public final class FTS4ColumnDefinition { fileprivate let name: String fileprivate var isIndexed: Bool fileprivate var isLanguageId: Bool init(name: String) { self.name = name self.isIndexed = true self.isLanguageId = false } /// Excludes the column from the full-text index. /// /// For example: /// /// ```swift /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("a") /// t.column("b").notIndexed() /// } /// ``` /// /// Related SQLite documentation: /// /// - returns: `self` so that you can further refine the column definition. @discardableResult public func notIndexed() -> Self { self.isIndexed = false return self } /// Uses the column as the language id hidden column. /// /// For example: /// /// ```swift /// try db.create(virtualTable: "document", using: FTS4()) { t in /// t.column("a") /// t.column("lid").asLanguageId() /// } /// ``` /// /// Related SQLite documentation: /// /// - returns: `self` so that you can further refine the column definition. @discardableResult public func asLanguageId() -> Self { self.isLanguageId = true return self } } // Explicit non-conformance to Sendable: `FTS4ColumnDefinition` is a mutable // class and there is no known reason for making it thread-safe. @available(*, unavailable) extension FTS4ColumnDefinition: Sendable { } extension Database { /// Deletes the synchronization triggers for a synchronized FTS4 table. /// /// See ``FTS4TableDefinition/synchronize(withTable:)``. public func dropFTS4SynchronizationTriggers(forTable tableName: String) throws { try execute(sql: """ DROP TRIGGER IF EXISTS \("__\(tableName)_bu".quotedDatabaseIdentifier); DROP TRIGGER IF EXISTS \("__\(tableName)_bd".quotedDatabaseIdentifier); DROP TRIGGER IF EXISTS \("__\(tableName)_au".quotedDatabaseIdentifier); DROP TRIGGER IF EXISTS \("__\(tableName)_ai".quotedDatabaseIdentifier); """) } }