Files

403 lines
14 KiB
Swift

/// 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: <https://www.sqlite.org/fts3.html>
///
/// ## 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: <https://www.sqlite.org/fts3.html#creating_and_destroying_fts_tables>
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: <https://www.sqlite.org/fts3.html#the_content_option_>
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: <https://www.sqlite.org/fts3.html#the_compress_and_uncompress_options>
public var compress: String?
/// The FTS4 `uncompress` option.
///
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#the_compress_and_uncompress_options>
public var uncompress: String?
/// The FTS4 `matchinfo` option.
///
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#the_matchinfo_option>
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: <https://www.sqlite.org/fts3.html#the_prefix_option>
public var prefixes: Set<Int>?
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: <https://www.sqlite.org/fts3.html#_external_content_fts4_tables_>
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: <https://www.sqlite.org/fts3.html>
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: <https://www.sqlite.org/fts3.html#the_notindexed_option>
///
/// - 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: <https://www.sqlite.org/fts3.html#the_languageid_option>
///
/// - 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);
""")
}
}