add swiftUI code
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/// The virtual table module for the FTS3 full-text engine.
|
||||
///
|
||||
/// To create FTS3 tables, use the ``Database`` method
|
||||
/// ``Database/create(virtualTable:ifNotExists:using:_:)``:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts3(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS3()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### The FTS3 Module
|
||||
///
|
||||
/// - ``init()``
|
||||
/// - ``FTS3TableDefinition``
|
||||
/// - ``FTS3TokenizerDescriptor``
|
||||
///
|
||||
/// ### Full-Text Search Pattern
|
||||
///
|
||||
/// - ``FTS3Pattern``
|
||||
///
|
||||
/// ### Tokenizing Strings
|
||||
///
|
||||
/// - ``tokenize(_:withTokenizer:)``
|
||||
public struct FTS3 {
|
||||
/// Options for Latin script characters.
|
||||
public enum Diacritics: Sendable {
|
||||
/// Do not remove diacritics from Latin script characters. This option
|
||||
/// matches the `remove_diacritics=0` tokenizer argument.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
case keep
|
||||
|
||||
/// Remove diacritics from Latin script characters. This option matches
|
||||
/// the `remove_diacritics=1` tokenizer argument.
|
||||
case removeLegacy
|
||||
|
||||
#if GRDBCUSTOMSQLITE
|
||||
/// Remove diacritics from Latin script characters. This option matches
|
||||
/// the `remove_diacritics=2` tokenizer argument.
|
||||
case remove
|
||||
#elseif !GRDBCIPHER
|
||||
/// Remove diacritics from Latin script characters. This option matches
|
||||
/// the `remove_diacritics=2` tokenizer argument.
|
||||
@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.27+
|
||||
case remove
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates an FTS3 module.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts3(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS3()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// See ``Database/create(virtualTable:ifNotExists:using:_:)``
|
||||
public init() { }
|
||||
|
||||
/// Returns an array of tokens found in the string argument.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // ["sqlite", "database"]
|
||||
/// try FTS3.tokenize("SQLite database")
|
||||
///
|
||||
/// // ["gustave", "doré"])
|
||||
/// try FTS3.tokenize("Gustave Doré")
|
||||
/// ```
|
||||
///
|
||||
/// Results can be altered with the `tokenizer` argument:
|
||||
///
|
||||
/// ```swift
|
||||
/// // ["sqlite", "databas"]
|
||||
/// try FTS3.tokenize("SQLite database", withTokenizer: .porter)
|
||||
///
|
||||
/// // ["gustave", "dore"])
|
||||
/// try FTS3.tokenize("Gustave Doré", withTokenizer: .unicode61())
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation:
|
||||
///
|
||||
/// - <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
/// - <https://www.sqlite.org/fts3.html#querying_tokenizers>
|
||||
///
|
||||
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
||||
public static func tokenize(
|
||||
_ string: String,
|
||||
withTokenizer tokenizer: FTS3TokenizerDescriptor = .simple)
|
||||
throws -> [String]
|
||||
{
|
||||
try DatabaseQueue().inDatabase { db in
|
||||
var tokenizerChunks: [String] = []
|
||||
tokenizerChunks.append(tokenizer.name)
|
||||
for option in tokenizer.arguments {
|
||||
tokenizerChunks.append("\"\(option)\"")
|
||||
}
|
||||
let tokenizerSQL = tokenizerChunks.joined(separator: ", ")
|
||||
try db.execute(sql: "CREATE VIRTUAL TABLE tokens USING fts3tokenize(\(tokenizerSQL))")
|
||||
return try String.fetchAll(db, sql: """
|
||||
SELECT token FROM tokens WHERE input = ? ORDER BY position
|
||||
""", arguments: [string])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FTS3: VirtualTableModule {
|
||||
public var moduleName: String { "fts3" }
|
||||
|
||||
public func makeTableDefinition(configuration: VirtualTableConfiguration) -> FTS3TableDefinition {
|
||||
FTS3TableDefinition()
|
||||
}
|
||||
|
||||
public func moduleArguments(for definition: FTS3TableDefinition, in db: Database) -> [String] {
|
||||
var arguments = definition.columns
|
||||
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: " "))
|
||||
}
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
public func database(_ db: Database, didCreate tableName: String, using definition: FTS3TableDefinition) { }
|
||||
}
|
||||
|
||||
/// A `FTS3TableDefinition` lets you define the components of an FTS3
|
||||
/// 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: FTS3()) { t in // t is FTS3TableDefinition
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
public final class FTS3TableDefinition {
|
||||
fileprivate var columns: [String] = []
|
||||
|
||||
/// The virtual table tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE documents USING fts3(tokenize=porter)
|
||||
/// try db.create(virtualTable: "document", using: FTS3()) { t in
|
||||
/// t.tokenizer = .porter
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#creating_and_destroying_fts_tables>
|
||||
public var tokenizer: FTS3TokenizerDescriptor?
|
||||
|
||||
/// Appends a table column.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts3(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS3()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter name: the column name.
|
||||
public func column(_ name: String) {
|
||||
columns.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit non-conformance to Sendable: `FTS3TableDefinition` is a mutable
|
||||
// class and there is no known reason for making it thread-safe.
|
||||
@available(*, unavailable)
|
||||
extension FTS3TableDefinition: Sendable { }
|
||||
@@ -0,0 +1,150 @@
|
||||
/// A full text pattern for querying FTS3 virtual tables.
|
||||
///
|
||||
/// `FTS3Pattern` can be used with both ``FTS3`` and ``FTS4`` tables.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#full_text_index_queries>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Creating Raw FTS3 Patterns
|
||||
///
|
||||
/// - ``init(rawPattern:)``
|
||||
///
|
||||
/// ### Creating FTS3 Patterns from User Input
|
||||
///
|
||||
/// - ``init(matchingAllPrefixesIn:)``
|
||||
/// - ``init(matchingAllTokensIn:)``
|
||||
/// - ``init(matchingAnyTokenIn:)``
|
||||
/// - ``init(matchingPhrase:)``
|
||||
public struct FTS3Pattern: Sendable {
|
||||
/// The raw pattern string.
|
||||
///
|
||||
/// It is guaranteed to be a valid FTS3/4 pattern.
|
||||
public let rawPattern: String
|
||||
|
||||
/// Creates a pattern from a raw pattern string.
|
||||
///
|
||||
/// The pattern syntax is documented at <https://www.sqlite.org/fts3.html#full_text_index_queries>
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // OK
|
||||
/// let pattern = try FTS3Pattern(rawPattern: "and")
|
||||
///
|
||||
/// // Throws an error: malformed MATCH expression: [AND]
|
||||
/// let pattern = try FTS3Pattern(rawPattern: "AND")
|
||||
/// ```
|
||||
///
|
||||
/// - throws: A ``DatabaseError`` if the pattern has an invalid syntax.
|
||||
public init(rawPattern: String) throws {
|
||||
// Correctness above all: use SQLite to validate the pattern.
|
||||
//
|
||||
// Invalid patterns have SQLite return an error on the first
|
||||
// call to sqlite3_step() on a statement that matches against
|
||||
// that pattern.
|
||||
do {
|
||||
try DatabaseQueue().inDatabase { db in
|
||||
try db.execute(literal: """
|
||||
CREATE VIRTUAL TABLE documents USING fts3();
|
||||
SELECT * FROM documents WHERE content MATCH \(rawPattern);
|
||||
""")
|
||||
}
|
||||
} catch let error as DatabaseError {
|
||||
// Remove private SQL & arguments from the thrown error
|
||||
throw DatabaseError(resultCode: error.extendedResultCode, message: error.message)
|
||||
}
|
||||
|
||||
// Pattern is valid
|
||||
self.rawPattern = rawPattern
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches any token found in the input string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS3Pattern(matchingAnyTokenIn: "") // nil
|
||||
/// FTS3Pattern(matchingAnyTokenIn: "foo bar") // foo OR bar
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS3 pattern.
|
||||
public init?(matchingAnyTokenIn string: String) {
|
||||
guard let tokens = try? FTS3.tokenize(string, withTokenizer: .simple),
|
||||
!tokens.isEmpty
|
||||
else { return nil }
|
||||
try? self.init(rawPattern: tokens.joined(separator: " OR "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches all tokens found in the input string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS3Pattern(matchingAllTokensIn: "") // nil
|
||||
/// FTS3Pattern(matchingAllTokensIn: "foo bar") // foo bar
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS3 pattern.
|
||||
public init?(matchingAllTokensIn string: String) {
|
||||
guard let tokens = try? FTS3.tokenize(string, withTokenizer: .simple),
|
||||
!tokens.isEmpty
|
||||
else { return nil }
|
||||
try? self.init(rawPattern: tokens.joined(separator: " "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches all token prefixes found in the input
|
||||
/// string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS3Pattern(matchingAllTokensIn: "") // nil
|
||||
/// FTS3Pattern(matchingAllTokensIn: "foo bar") // foo* bar*
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS3 pattern.
|
||||
public init?(matchingAllPrefixesIn string: String) {
|
||||
guard let tokens = try? FTS3.tokenize(string, withTokenizer: .simple),
|
||||
!tokens.isEmpty
|
||||
else { return nil }
|
||||
try? self.init(rawPattern: tokens.map { "\($0)*" }.joined(separator: " "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches a contiguous string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS3Pattern(matchingPhrase: "") // nil
|
||||
/// FTS3Pattern(matchingPhrase: "foo bar") // "foo bar"
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS3 pattern.
|
||||
public init?(matchingPhrase string: String) {
|
||||
guard let tokens = try? FTS3.tokenize(string, withTokenizer: .simple),
|
||||
!tokens.isEmpty
|
||||
else { return nil }
|
||||
try? self.init(rawPattern: "\"" + tokens.joined(separator: " ") + "\"")
|
||||
}
|
||||
}
|
||||
|
||||
extension FTS3Pattern: DatabaseValueConvertible {
|
||||
public var databaseValue: DatabaseValue {
|
||||
rawPattern.databaseValue
|
||||
}
|
||||
|
||||
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> FTS3Pattern? {
|
||||
String
|
||||
.fromDatabaseValue(dbValue)
|
||||
.flatMap { try? FTS3Pattern(rawPattern: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/// The descriptor for an ``FTS3`` tokenizer.
|
||||
///
|
||||
/// `FTS3TokenizerDescriptor` can be used in both ``FTS3`` and ``FTS4`` tables.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS4()) { t in
|
||||
/// t.tokenizer = .simple // FTS3TokenizerDescriptor
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Creating Tokenizer Descriptors
|
||||
///
|
||||
/// - ``porter``
|
||||
/// - ``simple``
|
||||
/// - ``unicode61(diacritics:separators:tokenCharacters:)``
|
||||
/// - ``FTS3/Diacritics``
|
||||
public struct FTS3TokenizerDescriptor: Sendable {
|
||||
let name: String
|
||||
let arguments: [String]
|
||||
|
||||
init(_ name: String, arguments: [String] = []) {
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
/// The simple tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS4()) { t in
|
||||
/// t.tokenizer = .simple
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
public static let simple = FTS3TokenizerDescriptor("simple")
|
||||
|
||||
/// The porter tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS4()) { t in
|
||||
/// t.tokenizer = .porter
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
public static let porter = FTS3TokenizerDescriptor("porter")
|
||||
|
||||
/// The unicode61 tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS4()) { t in
|
||||
/// t.tokenizer = .unicode61()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts3.html#tokenizer>
|
||||
///
|
||||
/// - parameters:
|
||||
/// - diacritics: By default SQLite will strip diacritics from
|
||||
/// latin characters.
|
||||
/// - separators: Unless empty (the default), SQLite will consider these
|
||||
/// characters as token separators.
|
||||
/// - tokenCharacters: Unless empty (the default), SQLite will consider
|
||||
/// these characters as token characters.
|
||||
public static func unicode61(
|
||||
diacritics: FTS3.Diacritics = .removeLegacy,
|
||||
separators: Set<Character> = [],
|
||||
tokenCharacters: Set<Character> = [])
|
||||
-> FTS3TokenizerDescriptor
|
||||
{
|
||||
_unicode61(diacritics: diacritics, separators: separators, tokenCharacters: tokenCharacters)
|
||||
}
|
||||
|
||||
private static func _unicode61(
|
||||
diacritics: FTS3.Diacritics,
|
||||
separators: Set<Character> = [],
|
||||
tokenCharacters: Set<Character> = [])
|
||||
-> FTS3TokenizerDescriptor
|
||||
{
|
||||
var arguments: [String] = []
|
||||
switch diacritics {
|
||||
case .removeLegacy:
|
||||
break
|
||||
case .keep:
|
||||
arguments.append("remove_diacritics=0")
|
||||
#if GRDBCUSTOMSQLITE
|
||||
case .remove:
|
||||
arguments.append("remove_diacritics=2")
|
||||
#elseif !GRDBCIPHER
|
||||
case .remove:
|
||||
arguments.append("remove_diacritics=2")
|
||||
#endif
|
||||
}
|
||||
if !separators.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as separators, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
arguments.append("separators=" + separators.sorted().map { String($0) }.joined())
|
||||
}
|
||||
if !tokenCharacters.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as tokenCharacters, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
arguments.append("tokenchars=" + tokenCharacters.sorted().map { String($0) }.joined())
|
||||
}
|
||||
return FTS3TokenizerDescriptor("unicode61", arguments: arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/// 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);
|
||||
""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
import Foundation
|
||||
|
||||
/// The virtual table module for the FTS5 full-text engine.
|
||||
///
|
||||
/// To create FTS5 tables, use the ``Database`` method
|
||||
/// ``Database/create(virtualTable:ifNotExists:using:_:)``:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts5(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### The FTS5 Module
|
||||
///
|
||||
/// - ``init()``
|
||||
/// - ``FTS5TableDefinition``
|
||||
/// - ``FTS5ColumnDefinition``
|
||||
/// - ``FTS5TokenizerDescriptor``
|
||||
///
|
||||
/// ### Full-Text Search Pattern
|
||||
///
|
||||
/// - ``FTS5Pattern``
|
||||
///
|
||||
/// ### FTS5 Tokenizers
|
||||
///
|
||||
/// - ``FTS5Tokenizer``
|
||||
/// - ``FTS5CustomTokenizer``
|
||||
/// - ``FTS5WrapperTokenizer``
|
||||
/// - ``FTS5TokenFlags``
|
||||
/// - ``FTS5Tokenization``
|
||||
///
|
||||
/// ### Low-Level FTS5 Customization
|
||||
///
|
||||
/// - ``api(_:)``
|
||||
public struct FTS5 {
|
||||
/// Options for Latin script characters. Matches the raw "remove_diacritics"
|
||||
/// tokenizer argument.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#unicode61_tokenizer>
|
||||
public enum Diacritics: Sendable {
|
||||
/// Do not remove diacritics from Latin script characters. This
|
||||
/// option matches the raw "remove_diacritics=0" tokenizer argument.
|
||||
case keep
|
||||
/// Remove diacritics from Latin script characters. This
|
||||
/// option matches the raw "remove_diacritics=1" tokenizer argument.
|
||||
case removeLegacy
|
||||
#if GRDBCUSTOMSQLITE
|
||||
/// Remove diacritics from Latin script characters. This
|
||||
/// option matches the raw "remove_diacritics=2" tokenizer argument,
|
||||
/// available from SQLite 3.27.0
|
||||
case remove
|
||||
#elseif !GRDBCIPHER
|
||||
/// Remove diacritics from Latin script characters. This
|
||||
/// option matches the raw "remove_diacritics=2" tokenizer argument,
|
||||
/// available from SQLite 3.27.0
|
||||
@available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) // SQLite 3.27+
|
||||
case remove
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates an FTS5 module.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts5(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// See ``Database/create(virtualTable:ifNotExists:using:_:)``
|
||||
public init() { }
|
||||
|
||||
// Support for FTS5Pattern initializers. Don't make public. Users tokenize
|
||||
// with `FTS5Tokenizer.tokenize()` methods, which support custom tokenizers,
|
||||
// token flags, and query/document tokenzation.
|
||||
/// Tokenizes the string argument as an FTS5 query.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// try FTS5.tokenize(query: "SQLite database") // ["sqlite", "database"]
|
||||
/// try FTS5.tokenize(query: "Gustave Doré") // ["gustave", "doré"])
|
||||
///
|
||||
/// Synonym (colocated) tokens are not present in the returned array. See
|
||||
/// `FTS5_TOKEN_COLOCATED` at <https://www.sqlite.org/fts5.html#custom_tokenizers>
|
||||
/// for more information.
|
||||
///
|
||||
/// - parameter string: The tokenized string.
|
||||
/// - returns: An array of tokens.
|
||||
/// - throws: An error if tokenization fails.
|
||||
static func tokenize(query string: String) throws -> [String] {
|
||||
try DatabaseQueue().inDatabase { db in
|
||||
try db.makeTokenizer(.ascii()).tokenize(query: string).compactMap {
|
||||
$0.flags.contains(.colocated) ? nil : $0.token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a pointer to the `fts5_api` structure.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#extending_fts5>
|
||||
public static func api(_ db: Database) -> UnsafePointer<fts5_api> {
|
||||
// Access to FTS5 is one of the rare SQLite api which was broken in
|
||||
// SQLite 3.20.0+, for security reasons:
|
||||
//
|
||||
// Starting SQLite 3.20.0+, we need to use the new sqlite3_bind_pointer api.
|
||||
// The previous way to access FTS5 does not work any longer.
|
||||
//
|
||||
// So let's see which SQLite version we are linked against:
|
||||
|
||||
#if GRDBCUSTOMSQLITE || GRDBCIPHER
|
||||
// GRDB is linked against SQLCipher or a custom SQLite build: SQLite 3.20.0 or more.
|
||||
return api_v2(db, sqlite3_prepare_v3, sqlite3_bind_pointer)
|
||||
#else
|
||||
// GRDB is linked against the system SQLite.
|
||||
if #available(iOS 12, macOS 10.14, tvOS 12, watchOS 5, *) { // SQLite 3.20+
|
||||
return api_v2(db, sqlite3_prepare_v3, sqlite3_bind_pointer)
|
||||
} else {
|
||||
return api_v1(db)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func api_v1(_ db: Database) -> UnsafePointer<fts5_api> {
|
||||
guard let data = try! Data.fetchOne(db, sql: "SELECT fts5()") else {
|
||||
fatalError("FTS5 is not available")
|
||||
}
|
||||
return data.withUnsafeBytes {
|
||||
$0.bindMemory(to: UnsafePointer<fts5_api>.self).first!
|
||||
}
|
||||
}
|
||||
|
||||
// Technique given by Jordan Rose:
|
||||
// https://forums.swift.org/t/c-interoperability-combinations-of-library-and-os-versions/14029/4
|
||||
private static func api_v2(
|
||||
_ db: Database,
|
||||
// swiftlint:disable:next line_length
|
||||
_ sqlite3_prepare_v3: @convention(c) (OpaquePointer?, UnsafePointer<CChar>?, CInt, CUnsignedInt, UnsafeMutablePointer<OpaquePointer?>?, UnsafeMutablePointer<UnsafePointer<CChar>?>?) -> CInt,
|
||||
// swiftlint:disable:next line_length
|
||||
_ sqlite3_bind_pointer: @convention(c) (OpaquePointer?, CInt, UnsafeMutableRawPointer?, UnsafePointer<CChar>?, (@convention(c) (UnsafeMutableRawPointer?) -> Void)?) -> CInt)
|
||||
-> UnsafePointer<fts5_api>
|
||||
{
|
||||
var statement: SQLiteStatement? = nil
|
||||
var api: UnsafePointer<fts5_api>? = nil
|
||||
let type: StaticString = "fts5_api_ptr"
|
||||
|
||||
let code = sqlite3_prepare_v3(db.sqliteConnection, "SELECT fts5(?)", -1, 0, &statement, nil)
|
||||
guard code == SQLITE_OK else {
|
||||
fatalError("FTS5 is not available")
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
type.utf8Start.withMemoryRebound(to: CChar.self, capacity: type.utf8CodeUnitCount) { typePointer in
|
||||
_ = sqlite3_bind_pointer(statement, 1, &api, typePointer, nil)
|
||||
}
|
||||
sqlite3_step(statement)
|
||||
guard let api else {
|
||||
fatalError("FTS5 is not available")
|
||||
}
|
||||
return api
|
||||
}
|
||||
}
|
||||
|
||||
extension FTS5: VirtualTableModule {
|
||||
/// The virtual table module name
|
||||
public var moduleName: String { "fts5" }
|
||||
|
||||
/// Reserved; part of the VirtualTableModule protocol.
|
||||
///
|
||||
/// See Database.create(virtualTable:using:)
|
||||
public func makeTableDefinition(configuration: VirtualTableConfiguration) -> FTS5TableDefinition {
|
||||
FTS5TableDefinition(configuration: configuration)
|
||||
}
|
||||
|
||||
/// Don't use this method.
|
||||
public func moduleArguments(for definition: FTS5TableDefinition, in db: Database) throws -> [String] {
|
||||
var arguments: [String] = []
|
||||
|
||||
if definition.columns.isEmpty {
|
||||
// Programmer error
|
||||
fatalError("FTS5 virtual table requires at least one column.")
|
||||
}
|
||||
|
||||
for column in definition.columns {
|
||||
if column.isIndexed {
|
||||
arguments.append("\(column.name)")
|
||||
} else {
|
||||
arguments.append("\(column.name) UNINDEXED")
|
||||
}
|
||||
}
|
||||
|
||||
if let tokenizer = definition.tokenizer {
|
||||
let tokenizerSQL = try tokenizer
|
||||
.components
|
||||
.map { component in
|
||||
try component.sqlExpression.quotedSQL(db)
|
||||
}
|
||||
.joined(separator: " ")
|
||||
.sqlExpression
|
||||
.quotedSQL(db)
|
||||
arguments.append("tokenize=\(tokenizerSQL)")
|
||||
}
|
||||
|
||||
switch definition.contentMode {
|
||||
case let .raw(content, contentRowID):
|
||||
if let content {
|
||||
let quotedContent = try content.sqlExpression.quotedSQL(db)
|
||||
arguments.append("content=\(quotedContent)")
|
||||
}
|
||||
if let contentRowID {
|
||||
let quotedContentRowID = try contentRowID.sqlExpression.quotedSQL(db)
|
||||
arguments.append("content_rowid=\(quotedContentRowID)")
|
||||
}
|
||||
case let .synchronized(contentTable):
|
||||
try arguments.append("content=\(contentTable.sqlExpression.quotedSQL(db))")
|
||||
if let rowIDColumn = try db.primaryKey(contentTable).rowIDColumn {
|
||||
let quotedRowID = try rowIDColumn.sqlExpression.quotedSQL(db)
|
||||
arguments.append("content_rowid=\(quotedRowID)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let prefixes = definition.prefixes {
|
||||
let prefix = try prefixes
|
||||
.sorted()
|
||||
.map { "\($0)" }
|
||||
.joined(separator: " ")
|
||||
.sqlExpression
|
||||
.quotedSQL(db)
|
||||
arguments.append("prefix=\(prefix)")
|
||||
}
|
||||
|
||||
if let columnSize = definition.columnSize {
|
||||
arguments.append("columnSize=\(columnSize)")
|
||||
}
|
||||
|
||||
if let detail = definition.detail {
|
||||
arguments.append("detail=\(detail)")
|
||||
}
|
||||
|
||||
return arguments
|
||||
}
|
||||
|
||||
/// Reserved; part of the VirtualTableModule protocol.
|
||||
///
|
||||
/// See Database.create(virtualTable:using:)
|
||||
public func database(_ db: Database, didCreate tableName: String, using definition: FTS5TableDefinition) throws {
|
||||
switch definition.contentMode {
|
||||
case .raw:
|
||||
break
|
||||
case .synchronized(let contentTable):
|
||||
// https://sqlite.org/fts5.html#external_content_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 = (["rowid"] + indexedColumns)
|
||||
.map(\.quotedDatabaseIdentifier)
|
||||
.joined(separator: ", ")
|
||||
|
||||
let newContentColumns = ([rowIDColumn] + indexedColumns)
|
||||
.map { "new.\($0.quotedDatabaseIdentifier)" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let oldContentColumns = ([rowIDColumn] + indexedColumns)
|
||||
.map { "old.\($0.quotedDatabaseIdentifier)" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let ifNotExists = definition.configuration.ifNotExists
|
||||
? "IF NOT EXISTS "
|
||||
: ""
|
||||
|
||||
// swiftlint:disable line_length
|
||||
try db.execute(sql: """
|
||||
CREATE TRIGGER \(ifNotExists)\("__\(tableName)_ai".quotedDatabaseIdentifier) AFTER INSERT ON \(content) BEGIN
|
||||
INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES (\(newContentColumns));
|
||||
END;
|
||||
CREATE TRIGGER \(ifNotExists)\("__\(tableName)_ad".quotedDatabaseIdentifier) AFTER DELETE ON \(content) BEGIN
|
||||
INSERT INTO \(ftsTable)(\(ftsTable), \(ftsColumns)) VALUES('delete', \(oldContentColumns));
|
||||
END;
|
||||
CREATE TRIGGER \(ifNotExists)\("__\(tableName)_au".quotedDatabaseIdentifier) AFTER UPDATE ON \(content) BEGIN
|
||||
INSERT INTO \(ftsTable)(\(ftsTable), \(ftsColumns)) VALUES('delete', \(oldContentColumns));
|
||||
INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES (\(newContentColumns));
|
||||
END;
|
||||
""")
|
||||
// swiftlint:enable line_length
|
||||
|
||||
// https://sqlite.org/fts5.html#the_rebuild_command
|
||||
|
||||
try db.execute(sql: "INSERT INTO \(ftsTable)(\(ftsTable)) VALUES('rebuild')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `FTS5TableDefinition` lets you define the components of an FTS5
|
||||
/// 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: FTS5()) { t in // t is FTS5TableDefinition
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Define Columns
|
||||
///
|
||||
/// - ``column(_:)``
|
||||
///
|
||||
/// ### External Content Tables
|
||||
///
|
||||
/// - ``synchronize(withTable:)``
|
||||
///
|
||||
/// ### FTS5 Options
|
||||
///
|
||||
/// - ``columnSize``
|
||||
/// - ``content``
|
||||
/// - ``contentRowID``
|
||||
/// - ``detail``
|
||||
/// - ``prefixes``
|
||||
/// - ``tokenizer``
|
||||
public final class FTS5TableDefinition {
|
||||
enum ContentMode {
|
||||
case raw(content: String?, contentRowID: String?)
|
||||
case synchronized(contentTable: String)
|
||||
}
|
||||
|
||||
fileprivate let configuration: VirtualTableConfiguration
|
||||
fileprivate var columns: [FTS5ColumnDefinition] = []
|
||||
fileprivate var contentMode: ContentMode = .raw(content: nil, contentRowID: nil)
|
||||
|
||||
/// The virtual table tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE "documents" USING fts5(tokenize=porter)
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.tokenizer = .porter()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#fts5_table_creation_and_initialization>
|
||||
public var tokenizer: FTS5TokenizerDescriptor?
|
||||
|
||||
/// The FTS5 `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/fts5.html#external_content_and_contentless_tables>
|
||||
public var content: String? {
|
||||
get {
|
||||
switch contentMode {
|
||||
case .raw(let content, _):
|
||||
return content
|
||||
case .synchronized(let contentTable):
|
||||
return contentTable
|
||||
}
|
||||
}
|
||||
set {
|
||||
switch contentMode {
|
||||
case .raw(_, let contentRowID):
|
||||
contentMode = .raw(content: newValue, contentRowID: contentRowID)
|
||||
case .synchronized:
|
||||
contentMode = .raw(content: newValue, contentRowID: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The FTS5 `content_rowid` 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/fts5.html#external_content_tables>
|
||||
public var contentRowID: String? {
|
||||
get {
|
||||
switch contentMode {
|
||||
case .raw(_, let contentRowID):
|
||||
return contentRowID
|
||||
case .synchronized:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
set {
|
||||
switch contentMode {
|
||||
case .raw(let content, _):
|
||||
contentMode = .raw(content: content, contentRowID: newValue)
|
||||
case .synchronized:
|
||||
contentMode = .raw(content: nil, contentRowID: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The FTS5 `prefix` option.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#prefix_indexes>
|
||||
public var prefixes: Set<Int>?
|
||||
|
||||
/// The FTS5 `columnsize` option.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#the_columnsize_option>
|
||||
public var columnSize: Int?
|
||||
|
||||
/// The FTS5 `detail` option.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#the_detail_option>
|
||||
public var detail: String?
|
||||
|
||||
init(configuration: VirtualTableConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Appends a table column.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // CREATE VIRTUAL TABLE document USING fts5(content)
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.column("content")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter name: the column name.
|
||||
/// - returns: A ``FTS5ColumnDefinition`` that allows you to refine the
|
||||
/// column definition.
|
||||
@discardableResult
|
||||
public func column(_ name: String) -> FTS5ColumnDefinition {
|
||||
let column = FTS5ColumnDefinition(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/dropFTS5SynchronizationTriggers(forTable:)`` method:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Create tables
|
||||
/// try db.create(table: "book") { t in
|
||||
/// ...
|
||||
/// }
|
||||
/// try db.create(virtualTable: "book_ft", using: FTS5()) { t in
|
||||
/// t.synchronize(withTable: "book")
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// // Drop full-text table
|
||||
/// try db.drop(table: "book_ft")
|
||||
/// try db.dropFTS5SynchronizationTriggers(forTable: "book_ft")
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://sqlite.org/fts5.html#external_content_tables>
|
||||
public func synchronize(withTable tableName: String) {
|
||||
contentMode = .synchronized(contentTable: tableName)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit non-conformance to Sendable: `FTS5TableDefinition` is a mutable
|
||||
// class and there is no known reason for making it thread-safe.
|
||||
@available(*, unavailable)
|
||||
extension FTS5TableDefinition: Sendable { }
|
||||
|
||||
/// Describes a column in an ``FTS5`` virtual table.
|
||||
///
|
||||
/// You get instances of `FTS5ColumnDefinition` when you create an ``FTS5``
|
||||
/// virtual table. For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.column("content") // FTS5ColumnDefinition
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html>
|
||||
public final class FTS5ColumnDefinition {
|
||||
fileprivate let name: String
|
||||
fileprivate var isIndexed: Bool
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.isIndexed = true
|
||||
}
|
||||
|
||||
/// Excludes the column from the full-text index.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
/// t.column("a")
|
||||
/// t.column("b").notIndexed()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#the_unindexed_column_option>
|
||||
///
|
||||
/// - returns: `self` so that you can further refine the column definition.
|
||||
@discardableResult
|
||||
public func notIndexed() -> Self {
|
||||
self.isIndexed = false
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit non-conformance to Sendable: `FTS5ColumnDefinition` is a mutable
|
||||
// class and there is no known reason for making it thread-safe.
|
||||
@available(*, unavailable)
|
||||
extension FTS5ColumnDefinition: Sendable { }
|
||||
|
||||
extension Column {
|
||||
/// The ``FTS5`` rank column.
|
||||
public static let rank = Column("rank")
|
||||
}
|
||||
|
||||
extension Database {
|
||||
/// Deletes the synchronization triggers for a synchronized FTS5 table.
|
||||
public func dropFTS5SynchronizationTriggers(forTable tableName: String) throws {
|
||||
try execute(sql: """
|
||||
DROP TRIGGER IF EXISTS \("__\(tableName)_ai".quotedDatabaseIdentifier);
|
||||
DROP TRIGGER IF EXISTS \("__\(tableName)_ad".quotedDatabaseIdentifier);
|
||||
DROP TRIGGER IF EXISTS \("__\(tableName)_au".quotedDatabaseIdentifier);
|
||||
""")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,172 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
/// A type that implements a custom tokenizer for the ``FTS5`` full-text engine.
|
||||
///
|
||||
/// See [FTS5 Tokenizers](https://github.com/groue/GRDB.swift/blob/master/Documentation/FTS5Tokenizers.md)
|
||||
/// for more information.
|
||||
public protocol FTS5CustomTokenizer: FTS5Tokenizer {
|
||||
/// The name of the tokenizer.
|
||||
///
|
||||
/// The name should uniquely identify the tokenizer: don't use a built-in
|
||||
/// name such as `ascii`, `porter` or `unicode61`.
|
||||
static var name: String { get }
|
||||
|
||||
/// Creates a custom tokenizer.
|
||||
///
|
||||
/// The arguments parameter is an array of String built from the CREATE
|
||||
/// VIRTUAL TABLE statement. In the example below, the arguments will
|
||||
/// be `["arg1", "arg2"]`.
|
||||
///
|
||||
/// CREATE VIRTUAL TABLE document USING fts5(
|
||||
/// tokenize='custom arg1 arg2'
|
||||
/// )
|
||||
///
|
||||
/// - parameter db: A Database connection
|
||||
/// - parameter arguments: An array of string arguments
|
||||
init(db: Database, arguments: [String]) throws
|
||||
}
|
||||
|
||||
extension FTS5CustomTokenizer {
|
||||
|
||||
/// Creates an FTS5 tokenizer descriptor.
|
||||
///
|
||||
/// class MyTokenizer : FTS5CustomTokenizer { ... }
|
||||
///
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// let tokenizer = MyTokenizer.tokenizerDescriptor(arguments: ["unicode61", "remove_diacritics", "0"])
|
||||
/// t.tokenizer = tokenizer
|
||||
/// }
|
||||
public static func tokenizerDescriptor(arguments: [String] = []) -> FTS5TokenizerDescriptor {
|
||||
FTS5TokenizerDescriptor(components: [name] + arguments)
|
||||
}
|
||||
}
|
||||
|
||||
extension Database {
|
||||
|
||||
// MARK: - FTS5
|
||||
|
||||
private class FTS5TokenizerConstructor {
|
||||
let db: Database
|
||||
let constructor: (Database, [String], UnsafeMutablePointer<OpaquePointer?>?) -> CInt
|
||||
|
||||
init(
|
||||
db: Database,
|
||||
constructor: @escaping (Database, [String], UnsafeMutablePointer<OpaquePointer?>?) -> CInt)
|
||||
{
|
||||
self.db = db
|
||||
self.constructor = constructor
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a custom FTS5 tokenizer.
|
||||
///
|
||||
/// class MyTokenizer : FTS5CustomTokenizer { ... }
|
||||
/// db.add(tokenizer: MyTokenizer.self)
|
||||
public func add(tokenizer: (some FTS5CustomTokenizer).Type) {
|
||||
let api = FTS5.api(self)
|
||||
|
||||
// Swift won't let the @convention(c) xCreate() function below create
|
||||
// an instance of the generic Tokenizer type.
|
||||
//
|
||||
// We thus hide the generic Tokenizer type inside a neutral type:
|
||||
// FTS5TokenizerConstructor
|
||||
let constructor = FTS5TokenizerConstructor(
|
||||
db: self,
|
||||
constructor: { (db, arguments, tokenizerHandle) in
|
||||
guard let tokenizerHandle else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
do {
|
||||
let tokenizer = try tokenizer.init(db: db, arguments: arguments)
|
||||
|
||||
// Tokenizer must remain alive until xDeleteTokenizer()
|
||||
// is called, as the xDelete member of xTokenizer
|
||||
let tokenizerPointer = OpaquePointer(Unmanaged.passRetained(tokenizer).toOpaque())
|
||||
|
||||
tokenizerHandle.pointee = tokenizerPointer
|
||||
return SQLITE_OK
|
||||
} catch let error as DatabaseError {
|
||||
return error.extendedResultCode.rawValue
|
||||
} catch {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
})
|
||||
|
||||
// Constructor must remain alive until deleteConstructor() is
|
||||
// called, as the last argument of the xCreateTokenizer() function.
|
||||
let constructorPointer = Unmanaged.passRetained(constructor).toOpaque()
|
||||
|
||||
func deleteConstructor(constructorPointer: UnsafeMutableRawPointer?) {
|
||||
guard let constructorPointer else { return }
|
||||
Unmanaged<AnyObject>.fromOpaque(constructorPointer).release()
|
||||
}
|
||||
|
||||
func xCreateTokenizer(
|
||||
constructorPointer: UnsafeMutableRawPointer?,
|
||||
azArg: UnsafeMutablePointer<UnsafePointer<Int8>?>?,
|
||||
nArg: CInt,
|
||||
tokenizerHandle: UnsafeMutablePointer<OpaquePointer?>?)
|
||||
-> CInt
|
||||
{
|
||||
guard let constructorPointer else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
let constructor = Unmanaged<FTS5TokenizerConstructor>.fromOpaque(constructorPointer).takeUnretainedValue()
|
||||
var arguments: [String] = []
|
||||
if let azArg {
|
||||
for i in 0..<Int(nArg) {
|
||||
if let cstr = azArg[i] {
|
||||
arguments.append(String(cString: cstr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return constructor.constructor(constructor.db, arguments, tokenizerHandle)
|
||||
}
|
||||
|
||||
func xDeleteTokenizer(tokenizerPointer: OpaquePointer?) {
|
||||
guard let tokenizerPointer else { return }
|
||||
Unmanaged<AnyObject>.fromOpaque(UnsafeMutableRawPointer(tokenizerPointer)).release()
|
||||
}
|
||||
|
||||
func xTokenize(
|
||||
tokenizerPointer: OpaquePointer?,
|
||||
context: UnsafeMutableRawPointer?,
|
||||
flags: CInt,
|
||||
pText: UnsafePointer<CChar>?,
|
||||
nText: CInt,
|
||||
// swiftlint:disable:next line_length
|
||||
tokenCallback: (@convention(c) (UnsafeMutableRawPointer?, CInt, UnsafePointer<CChar>?, CInt, CInt, CInt) -> CInt)?)
|
||||
-> CInt
|
||||
{
|
||||
guard let tokenizerPointer else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
let object = Unmanaged<AnyObject>
|
||||
.fromOpaque(UnsafeMutableRawPointer(tokenizerPointer))
|
||||
.takeUnretainedValue()
|
||||
guard let tokenizer = object as? any FTS5Tokenizer else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
return tokenizer.tokenize(
|
||||
context: context,
|
||||
tokenization: FTS5Tokenization(rawValue: flags),
|
||||
pText: pText,
|
||||
nText: nText,
|
||||
tokenCallback: tokenCallback!)
|
||||
}
|
||||
|
||||
var xTokenizer = fts5_tokenizer(xCreate: xCreateTokenizer, xDelete: xDeleteTokenizer, xTokenize: xTokenize)
|
||||
let code = withUnsafeMutablePointer(to: &xTokenizer) { xTokenizerPointer in
|
||||
api.pointee.xCreateTokenizer(
|
||||
UnsafeMutablePointer(mutating: api),
|
||||
tokenizer.name,
|
||||
constructorPointer,
|
||||
xTokenizerPointer,
|
||||
deleteConstructor)
|
||||
}
|
||||
guard code == SQLITE_OK else {
|
||||
// Assume a GRDB bug: there is no point throwing any error.
|
||||
fatalError(DatabaseError(resultCode: code, message: lastErrorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,197 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
/// A full text pattern for querying ``FTS5`` virtual tables.
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#full_text_query_syntax>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Creating Raw FTS5 Patterns
|
||||
///
|
||||
/// - ``Database/makeFTS5Pattern(rawPattern:forTable:)``
|
||||
///
|
||||
/// ### Creating FTS5 Patterns from User Input
|
||||
///
|
||||
/// - ``init(matchingAllPrefixesIn:)``
|
||||
/// - ``init(matchingAllTokensIn:)``
|
||||
/// - ``init(matchingAnyTokenIn:)``
|
||||
/// - ``init(matchingPhrase:)``
|
||||
/// - ``init(matchingPrefixPhrase:)``
|
||||
public struct FTS5Pattern: Sendable {
|
||||
|
||||
/// The raw pattern string.
|
||||
///
|
||||
/// It is guaranteed to be a valid FTS5 pattern.
|
||||
public let rawPattern: String
|
||||
|
||||
/// Creates a pattern that matches any token found in the input string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS5Pattern(matchingAnyTokenIn: "") // nil
|
||||
/// FTS5Pattern(matchingAnyTokenIn: "foo bar") // foo OR bar
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS5 pattern.
|
||||
public init?(matchingAnyTokenIn string: String) {
|
||||
guard let tokens = try? FTS5.tokenize(query: string), !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
try? self.init(rawPattern: tokens.joined(separator: " OR "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches all tokens found in the input string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS5Pattern(matchingAllTokensIn: "") // nil
|
||||
/// FTS5Pattern(matchingAllTokensIn: "foo bar") // foo bar
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS5 pattern.
|
||||
public init?(matchingAllTokensIn string: String) {
|
||||
guard let tokens = try? FTS5.tokenize(query: string), !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
try? self.init(rawPattern: tokens.joined(separator: " "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches all token prefixes found in the input
|
||||
/// string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS5Pattern(matchingAllTokensIn: "") // nil
|
||||
/// FTS5Pattern(matchingAllTokensIn: "foo bar") // foo* bar*
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS5 pattern.
|
||||
public init?(matchingAllPrefixesIn string: String) {
|
||||
guard let tokens = try? FTS5.tokenize(query: string), !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
try? self.init(rawPattern: tokens.map { "\($0)*" }.joined(separator: " "))
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches a contiguous string.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS5Pattern(matchingPhrase: "") // nil
|
||||
/// FTS5Pattern(matchingPhrase: "foo bar") // "foo bar"
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS5 pattern.
|
||||
public init?(matchingPhrase string: String) {
|
||||
guard let tokens = try? FTS5.tokenize(query: string), !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
try? self.init(rawPattern: "\"" + tokens.joined(separator: " ") + "\"")
|
||||
}
|
||||
|
||||
/// Creates a pattern that matches the prefix of an indexed document.
|
||||
///
|
||||
/// The result is nil if no pattern could be built.
|
||||
///
|
||||
/// The returned pattern matches a prefix made of full tokens: "the bat"
|
||||
/// matches "the bat is happy", but not "mind the bat", or "the batcave
|
||||
/// is dark".
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// FTS5Pattern(matchingPrefixPhrase: "") // nil
|
||||
/// FTS5Pattern(matchingPrefixPhrase: "the word") // ^"the word"
|
||||
/// ```
|
||||
///
|
||||
/// - parameter string: The string to turn into an FTS5 pattern
|
||||
public init?(matchingPrefixPhrase string: String) {
|
||||
guard let tokens = try? FTS5.tokenize(query: string), !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
try? self.init(rawPattern: "^\"" + tokens.joined(separator: " ") + "\"")
|
||||
}
|
||||
|
||||
init(rawPattern: String, allowedColumns: [String] = []) throws {
|
||||
// Correctness above all: use SQLite to validate the pattern.
|
||||
//
|
||||
// Invalid patterns have SQLite return an error on the first
|
||||
// call to sqlite3_step() on a statement that matches against
|
||||
// that pattern.
|
||||
do {
|
||||
try DatabaseQueue().inDatabase { db in
|
||||
try db.create(virtualTable: "document", using: FTS5()) { t in
|
||||
if allowedColumns.isEmpty {
|
||||
t.column("__grdb__")
|
||||
} else {
|
||||
for column in allowedColumns {
|
||||
t.column(column)
|
||||
}
|
||||
}
|
||||
}
|
||||
try db.makeStatement(sql: "SELECT * FROM document WHERE document MATCH ?")
|
||||
.makeCursor(arguments: [rawPattern])
|
||||
.next() // error on next() for invalid patterns
|
||||
}
|
||||
} catch let error as DatabaseError {
|
||||
// Remove private SQL & arguments from the thrown error
|
||||
throw DatabaseError(resultCode: error.extendedResultCode, message: error.message)
|
||||
}
|
||||
|
||||
// Pattern is valid
|
||||
self.rawPattern = rawPattern
|
||||
}
|
||||
}
|
||||
|
||||
extension Database {
|
||||
|
||||
// MARK: - FTS5
|
||||
|
||||
/// Creates an FTS5 pattern from a raw pattern string.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try dbQueue.read { db in
|
||||
/// // OK
|
||||
/// let pattern = try db.makeFTS5Pattern(rawPattern: "and", forTable: "document")
|
||||
///
|
||||
/// // Throws error: malformed MATCH expression: [AND]
|
||||
/// let pattern = try db.makeFTS5Pattern(rawPattern: "AND", forTable: "document")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter rawPattern: A pattern that follows the
|
||||
/// [Full-text Query Syntax](https://www.sqlite.org/fts5.html#full_text_query_syntax).
|
||||
/// - parameter table: The full-text table that the pattern is intended to
|
||||
/// match against.
|
||||
/// - returns: A valid FTS5 pattern.
|
||||
/// - throws: A ``DatabaseError`` if the raw pattern is invalid.
|
||||
public func makeFTS5Pattern(rawPattern: String, forTable table: String) throws -> FTS5Pattern {
|
||||
try FTS5Pattern(rawPattern: rawPattern, allowedColumns: columns(in: table).map(\.name))
|
||||
}
|
||||
}
|
||||
|
||||
extension FTS5Pattern: DatabaseValueConvertible {
|
||||
public var databaseValue: DatabaseValue {
|
||||
rawPattern.databaseValue
|
||||
}
|
||||
|
||||
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> FTS5Pattern? {
|
||||
String
|
||||
.fromDatabaseValue(dbValue)
|
||||
.flatMap { try? FTS5Pattern(rawPattern: $0) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,316 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
import Foundation
|
||||
|
||||
/// A low-level SQLite function that lets FTS5Tokenizer notify tokens.
|
||||
///
|
||||
/// See ``FTS5Tokenizer/tokenize(context:tokenization:pText:nText:tokenCallback:)``.
|
||||
public typealias FTS5TokenCallback = @convention(c) (
|
||||
_ context: UnsafeMutableRawPointer?,
|
||||
_ flags: CInt,
|
||||
_ pToken: UnsafePointer<CChar>?,
|
||||
_ nToken: CInt,
|
||||
_ iStart: CInt,
|
||||
_ iEnd: CInt)
|
||||
-> CInt
|
||||
|
||||
/// The reason why FTS5 is requesting tokenization.
|
||||
///
|
||||
/// See the `FTS5_TOKENIZE_*` constants in <https://www.sqlite.org/fts5.html#custom_tokenizers>.
|
||||
public struct FTS5Tokenization: OptionSet, Sendable {
|
||||
public let rawValue: CInt
|
||||
|
||||
public init(rawValue: CInt) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
/// `FTS5_TOKENIZE_QUERY`
|
||||
public static let query = FTS5Tokenization(rawValue: FTS5_TOKENIZE_QUERY)
|
||||
|
||||
/// `FTS5_TOKENIZE_PREFIX`
|
||||
public static let prefix = FTS5Tokenization(rawValue: FTS5_TOKENIZE_PREFIX)
|
||||
|
||||
/// `FTS5_TOKENIZE_DOCUMENT`
|
||||
public static let document = FTS5Tokenization(rawValue: FTS5_TOKENIZE_DOCUMENT)
|
||||
|
||||
/// `FTS5_TOKENIZE_AUX`
|
||||
public static let aux = FTS5Tokenization(rawValue: FTS5_TOKENIZE_AUX)
|
||||
}
|
||||
|
||||
/// A type that implements a tokenizer for the ``FTS5`` full-text engine.
|
||||
///
|
||||
/// You can instantiate tokenizers, including
|
||||
/// [built-in tokenizers](https://www.sqlite.org/fts5.html#tokenizers),
|
||||
/// with the ``Database/makeTokenizer(_:)`` method:
|
||||
///
|
||||
/// ```swift
|
||||
/// try dbQueue.read { db in
|
||||
/// let unicode61 = try db.makeTokenizer(.unicode61()) // FTS5Tokenizer
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// See [FTS5 Tokenizers](https://github.com/groue/GRDB.swift/blob/master/Documentation/FTS5Tokenizers.md)
|
||||
/// for more information.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Tokenizing Text
|
||||
///
|
||||
/// - ``tokenize(document:)``
|
||||
/// - ``tokenize(query:)``
|
||||
/// - ``tokenize(context:tokenization:pText:nText:tokenCallback:)``
|
||||
/// - ``FTS5TokenCallback``
|
||||
public protocol FTS5Tokenizer: AnyObject {
|
||||
/// Tokenizes the text described by `pText` and `nText`, and
|
||||
/// notifies found tokens to the `tokenCallback` function.
|
||||
///
|
||||
/// It matches the `xTokenize` function documented at <https://www.sqlite.org/fts5.html#custom_tokenizers>
|
||||
///
|
||||
/// - parameters:
|
||||
/// - context: An opaque pointer that is the first argument to
|
||||
/// the `tokenCallback` function
|
||||
/// - tokenization: The reason why FTS5 is requesting tokenization.
|
||||
/// - pText: The tokenized text bytes. May or may not be
|
||||
/// nul-terminated.
|
||||
/// - nText: The number of bytes in the tokenized text.
|
||||
/// - tokenCallback: The function to call for each found token.
|
||||
/// It matches the `xToken` callback at <https://www.sqlite.org/fts5.html#custom_tokenizers>
|
||||
func tokenize(
|
||||
context: UnsafeMutableRawPointer?,
|
||||
tokenization: FTS5Tokenization,
|
||||
pText: UnsafePointer<CChar>?,
|
||||
nText: CInt,
|
||||
tokenCallback: @escaping FTS5TokenCallback)
|
||||
-> CInt
|
||||
}
|
||||
|
||||
private class TokenizeContext {
|
||||
var tokens: [(String, FTS5TokenFlags)] = []
|
||||
}
|
||||
|
||||
extension FTS5Tokenizer {
|
||||
|
||||
/// Tokenizes the string argument as a document that would be inserted into
|
||||
/// an FTS5 table.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let tokenizer = try db.makeTokenizer(.ascii())
|
||||
/// try tokenizer.tokenize(document: "foo bar") // [("foo", flags), ("bar", flags)]
|
||||
/// ```
|
||||
///
|
||||
/// See also `tokenize(query:)`.
|
||||
///
|
||||
/// - parameter string: The string to tokenize.
|
||||
/// - returns: An array of tokens and flags.
|
||||
/// - throws: An error if tokenization fails.
|
||||
public func tokenize(document string: String) throws -> [(token: String, flags: FTS5TokenFlags)] {
|
||||
try tokenize(string, for: .document)
|
||||
}
|
||||
|
||||
/// Tokenizes the string argument as an FTS5 query.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let tokenizer = try db.makeTokenizer(.ascii())
|
||||
/// try tokenizer.tokenize(query: "foo bar") // [("foo", flags), ("bar", flags)]
|
||||
/// ```
|
||||
///
|
||||
/// See also `tokenize(document:)`.
|
||||
///
|
||||
/// - parameter string: The string to tokenize.
|
||||
/// - returns: An array of tokens and flags.
|
||||
/// - throws: An error if tokenization fails.
|
||||
public func tokenize(query string: String) throws -> [(token: String, flags: FTS5TokenFlags)] {
|
||||
try tokenize(string, for: .query)
|
||||
}
|
||||
|
||||
/// Tokenizes the string argument.
|
||||
///
|
||||
/// let tokenizer = try db.makeTokenizer(.ascii())
|
||||
/// try tokenizer.tokenize("foo bar", for: .document) // [("foo", flags), ("bar", flags)]
|
||||
///
|
||||
/// - parameter string: The string to tokenize
|
||||
/// - parameter tokenization: The reason why tokenization is requested:
|
||||
/// - .document: Tokenize like a document being inserted into an FTS table.
|
||||
/// - .query: Tokenize like the search pattern of the MATCH operator.
|
||||
/// - parameter tokenizer: A FTS5TokenizerDescriptor such as .ascii()
|
||||
private func tokenize(_ string: String, for tokenization: FTS5Tokenization)
|
||||
throws -> [(token: String, flags: FTS5TokenFlags)]
|
||||
{
|
||||
try ContiguousArray(string.utf8).withUnsafeBufferPointer { buffer -> [(String, FTS5TokenFlags)] in
|
||||
guard let addr = buffer.baseAddress else {
|
||||
return []
|
||||
}
|
||||
let pText = UnsafeMutableRawPointer(mutating: addr).assumingMemoryBound(to: CChar.self)
|
||||
let nText = CInt(buffer.count)
|
||||
|
||||
var context = TokenizeContext()
|
||||
try withUnsafeMutablePointer(to: &context) { contextPointer in
|
||||
let code = tokenize(
|
||||
context: UnsafeMutableRawPointer(contextPointer),
|
||||
tokenization: tokenization,
|
||||
pText: pText,
|
||||
nText: nText,
|
||||
tokenCallback: { (contextPointer, flags, pToken, nToken, _ /* iStart */, _ /* iEnd */) in
|
||||
guard let contextPointer else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
|
||||
// Extract token
|
||||
guard let token = pToken.flatMap({ String(
|
||||
data: Data(
|
||||
bytesNoCopy: UnsafeMutableRawPointer(mutating: $0),
|
||||
count: Int(nToken),
|
||||
deallocator: .none),
|
||||
encoding: .utf8) })
|
||||
else {
|
||||
return SQLITE_OK
|
||||
}
|
||||
|
||||
let context = contextPointer.assumingMemoryBound(to: TokenizeContext.self).pointee
|
||||
context.tokens.append((token, FTS5TokenFlags(rawValue: flags)))
|
||||
return SQLITE_OK
|
||||
})
|
||||
if code != SQLITE_OK {
|
||||
throw DatabaseError(resultCode: code)
|
||||
}
|
||||
}
|
||||
return context.tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Database {
|
||||
|
||||
// MARK: - FTS5
|
||||
|
||||
/// Private type that makes a pre-registered FTS5 tokenizer available
|
||||
/// through the FTS5Tokenizer protocol.
|
||||
private final class FTS5RegisteredTokenizer: FTS5Tokenizer {
|
||||
let xTokenizer: fts5_tokenizer
|
||||
let tokenizerPointer: OpaquePointer
|
||||
|
||||
init(xTokenizer: fts5_tokenizer, contextPointer: UnsafeMutableRawPointer?, arguments: [String]) throws {
|
||||
guard let xCreate = xTokenizer.xCreate else {
|
||||
throw DatabaseError(message: "nil fts5_tokenizer.xCreate")
|
||||
}
|
||||
|
||||
self.xTokenizer = xTokenizer
|
||||
|
||||
var tokenizerPointer: OpaquePointer? = nil
|
||||
let code: CInt
|
||||
if arguments.isEmpty {
|
||||
code = xCreate(contextPointer, nil, 0, &tokenizerPointer)
|
||||
} else {
|
||||
func withArrayOfCStrings<Result>(
|
||||
_ input: [String],
|
||||
_ output: inout ContiguousArray<UnsafePointer<CChar>>,
|
||||
_ accessor: (ContiguousArray<UnsafePointer<CChar>>) -> Result)
|
||||
-> Result
|
||||
{
|
||||
if output.count == input.count {
|
||||
return accessor(output)
|
||||
} else {
|
||||
return input[output.count].withCString { (cString) -> Result in
|
||||
output.append(cString)
|
||||
return withArrayOfCStrings(input, &output, accessor)
|
||||
}
|
||||
}
|
||||
}
|
||||
var cStrings = ContiguousArray<UnsafePointer<CChar>>()
|
||||
cStrings.reserveCapacity(arguments.count)
|
||||
code = withArrayOfCStrings(arguments, &cStrings) { (cStrings) in
|
||||
cStrings.withUnsafeBufferPointer { azArg in
|
||||
xCreate(
|
||||
contextPointer,
|
||||
UnsafeMutablePointer(OpaquePointer(azArg.baseAddress!)),
|
||||
CInt(cStrings.count),
|
||||
&tokenizerPointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard code == SQLITE_OK else {
|
||||
throw DatabaseError(resultCode: code, message: "failed fts5_tokenizer.xCreate")
|
||||
}
|
||||
|
||||
if let tokenizerPointer {
|
||||
self.tokenizerPointer = tokenizerPointer
|
||||
} else {
|
||||
throw DatabaseError(resultCode: code, message: "nil tokenizer")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let delete = xTokenizer.xDelete {
|
||||
delete(tokenizerPointer)
|
||||
}
|
||||
}
|
||||
|
||||
func tokenize(
|
||||
context: UnsafeMutableRawPointer?,
|
||||
tokenization: FTS5Tokenization,
|
||||
pText: UnsafePointer<CChar>?,
|
||||
nText: CInt,
|
||||
tokenCallback: @escaping FTS5TokenCallback)
|
||||
-> CInt
|
||||
{
|
||||
guard let xTokenize = xTokenizer.xTokenize else {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
return xTokenize(tokenizerPointer, context, tokenization.rawValue, pText, nText, tokenCallback)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an FTS5 tokenizer, given its descriptor.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let unicode61 = try db.makeTokenizer(.unicode61())
|
||||
/// ```
|
||||
///
|
||||
/// You can use this method when you implement a custom wrapper tokenizer
|
||||
/// with ``FTS5WrapperTokenizer``:
|
||||
///
|
||||
/// ```swift
|
||||
/// final class MyTokenizer : FTS5WrapperTokenizer {
|
||||
/// var wrappedTokenizer: FTS5Tokenizer
|
||||
///
|
||||
/// init(db: Database, arguments: [String]) throws {
|
||||
/// wrappedTokenizer = try db.makeTokenizer(.unicode61())
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// It is a programmer error to use the tokenizer outside of a protected
|
||||
/// database queue, or after the database has been closed.
|
||||
public func makeTokenizer(_ descriptor: FTS5TokenizerDescriptor) throws -> any FTS5Tokenizer {
|
||||
let api = FTS5.api(self)
|
||||
|
||||
let xTokenizerPointer: UnsafeMutablePointer<fts5_tokenizer> = .allocate(capacity: 1)
|
||||
defer { xTokenizerPointer.deallocate() }
|
||||
|
||||
let contextHandle: UnsafeMutablePointer<UnsafeMutableRawPointer?> = .allocate(capacity: 1)
|
||||
defer { contextHandle.deallocate() }
|
||||
|
||||
let code = api.pointee.xFindTokenizer!(
|
||||
UnsafeMutablePointer(mutating: api),
|
||||
descriptor.name,
|
||||
contextHandle,
|
||||
xTokenizerPointer)
|
||||
|
||||
guard code == SQLITE_OK else {
|
||||
throw DatabaseError(resultCode: code)
|
||||
}
|
||||
|
||||
let contextPointer = contextHandle.pointee
|
||||
return try FTS5RegisteredTokenizer(
|
||||
xTokenizer: xTokenizerPointer.pointee,
|
||||
contextPointer: contextPointer,
|
||||
arguments: descriptor.arguments)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,214 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
/// The descriptor for an ``FTS5`` tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// t.tokenizer = .unicode61() // FTS5TokenizerDescriptor
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#tokenizers>
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Creating Tokenizer Descriptors
|
||||
///
|
||||
/// - ``init(components:)``
|
||||
/// - ``ascii(separators:tokenCharacters:)``
|
||||
/// - ``porter(wrapping:)``
|
||||
/// - ``unicode61(diacritics:categories:separators:tokenCharacters:)``
|
||||
/// - ``FTS5/Diacritics``
|
||||
///
|
||||
/// ### Instantiating Tokenizers
|
||||
///
|
||||
/// - ``Database/makeTokenizer(_:)``
|
||||
public struct FTS5TokenizerDescriptor: Sendable {
|
||||
/// The tokenizer components.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // ["unicode61"]
|
||||
/// FTS5TokenizerDescriptor.unicode61().components
|
||||
///
|
||||
/// // ["unicode61", "remove_diacritics", "0"]
|
||||
/// FTS5TokenizerDescriptor.unicode61(removeDiacritics: false)).components
|
||||
/// ```
|
||||
public let components: [String]
|
||||
|
||||
/// The tokenizer name.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // "unicode61"
|
||||
/// FTS5TokenizerDescriptor.unicode61().name
|
||||
///
|
||||
/// // "unicode61"
|
||||
/// FTS5TokenizerDescriptor.unicode61(removeDiacritics: false)).name
|
||||
/// ```
|
||||
var name: String { components[0] }
|
||||
|
||||
/// The tokenizer arguments.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // []
|
||||
/// FTS5TokenizerDescriptor.unicode61().components
|
||||
///
|
||||
/// // ["remove_diacritics", "0"]
|
||||
/// FTS5TokenizerDescriptor.unicode61(removeDiacritics: false)).components
|
||||
/// ```
|
||||
var arguments: [String] {
|
||||
Array(components.suffix(from: 1))
|
||||
}
|
||||
|
||||
/// Creates an FTS5 tokenizer descriptor.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// t.tokenizer = FTS5TokenizerDescriptor(components: [
|
||||
/// "porter",
|
||||
/// "unicode61",
|
||||
/// "remove_diacritics",
|
||||
/// "0"])
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - precondition: Components is not empty.
|
||||
public init(components: [String]) {
|
||||
GRDBPrecondition(!components.isEmpty, "FTS5TokenizerDescriptor requires at least one component")
|
||||
assert(!components.isEmpty)
|
||||
self.components = components
|
||||
}
|
||||
|
||||
/// The "ascii" tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// t.tokenizer = .ascii()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#ascii_tokenizer>
|
||||
///
|
||||
/// - parameters:
|
||||
/// - separators: Unless empty (the default), SQLite will consider
|
||||
/// these characters as token separators.
|
||||
/// - tokenCharacters: Unless empty (the default), SQLite will
|
||||
/// consider these characters as token characters.
|
||||
public static func ascii(
|
||||
separators: Set<Character> = [],
|
||||
tokenCharacters: Set<Character> = [])
|
||||
-> FTS5TokenizerDescriptor {
|
||||
var components: [String] = ["ascii"]
|
||||
if !separators.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as separators, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
components.append("separators")
|
||||
components.append(separators.sorted().map { String($0) }.joined())
|
||||
}
|
||||
if !tokenCharacters.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as tokenCharacters, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
components.append("tokenchars")
|
||||
components.append(tokenCharacters.sorted().map { String($0) }.joined())
|
||||
}
|
||||
return FTS5TokenizerDescriptor(components: components)
|
||||
}
|
||||
|
||||
/// The "porter" tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// t.tokenizer = .porter()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#porter_tokenizer>
|
||||
///
|
||||
/// - parameter base: An eventual wrapping tokenizer which replaces the
|
||||
/// default unicode61() base tokenizer.
|
||||
public static func porter(wrapping base: FTS5TokenizerDescriptor? = nil) -> FTS5TokenizerDescriptor {
|
||||
if let base {
|
||||
return FTS5TokenizerDescriptor(components: ["porter"] + base.components)
|
||||
} else {
|
||||
return FTS5TokenizerDescriptor(components: ["porter"])
|
||||
}
|
||||
}
|
||||
|
||||
/// The "unicode61" tokenizer.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// try db.create(virtualTable: "book", using: FTS5()) { t in
|
||||
/// t.tokenizer = .unicode61()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Related SQLite documentation: <https://www.sqlite.org/fts5.html#unicode61_tokenizer>
|
||||
///
|
||||
/// - parameters:
|
||||
/// - diacritics: By default SQLite will strip diacritics from
|
||||
/// latin characters.
|
||||
/// - categories: Unless empty (the default), SQLite will consider
|
||||
/// "L* N* Co" Unicode categories for tokens.
|
||||
/// - separators: Unless empty (the default), SQLite will consider
|
||||
/// these characters as token separators.
|
||||
/// - tokenCharacters: Unless empty (the default), SQLite will
|
||||
/// consider these characters as token characters.
|
||||
public static func unicode61(
|
||||
diacritics: FTS5.Diacritics = .removeLegacy,
|
||||
categories: String = "",
|
||||
separators: Set<Character> = [],
|
||||
tokenCharacters: Set<Character> = [])
|
||||
-> FTS5TokenizerDescriptor
|
||||
{
|
||||
var components: [String] = ["unicode61"]
|
||||
switch diacritics {
|
||||
case .removeLegacy:
|
||||
break
|
||||
case .keep:
|
||||
components.append(contentsOf: ["remove_diacritics", "0"])
|
||||
#if GRDBCUSTOMSQLITE
|
||||
case .remove:
|
||||
components.append(contentsOf: ["remove_diacritics", "2"])
|
||||
#elseif !GRDBCIPHER
|
||||
case .remove:
|
||||
components.append(contentsOf: ["remove_diacritics", "2"])
|
||||
#endif
|
||||
}
|
||||
if !categories.isEmpty {
|
||||
components.append("categories")
|
||||
components.append(categories)
|
||||
}
|
||||
if !separators.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as separators, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
components.append("separators")
|
||||
components.append(separators.sorted().map { String($0) }.joined())
|
||||
}
|
||||
if !tokenCharacters.isEmpty {
|
||||
// TODO: test "=" and "\"", "(" and ")" as tokenCharacters, with
|
||||
// both FTS3Pattern(matchingAnyTokenIn:tokenizer:)
|
||||
// and Database.create(virtualTable:using:)
|
||||
components.append("tokenchars")
|
||||
components.append(tokenCharacters.sorted().map { String($0) }.joined())
|
||||
}
|
||||
return FTS5TokenizerDescriptor(components: components)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,162 @@
|
||||
#if SQLITE_ENABLE_FTS5
|
||||
import Foundation
|
||||
|
||||
/// Flags that tell SQLite how to register a token.
|
||||
///
|
||||
/// See the `FTS5_TOKEN_*` constants in <https://www.sqlite.org/fts5.html#custom_tokenizers>.
|
||||
public struct FTS5TokenFlags: OptionSet, Sendable {
|
||||
public let rawValue: CInt
|
||||
|
||||
public init(rawValue: CInt) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
/// `FTS5_TOKEN_COLOCATED`
|
||||
public static let colocated = FTS5TokenFlags(rawValue: FTS5_TOKEN_COLOCATED)
|
||||
}
|
||||
|
||||
/// A function that lets FTS5WrapperTokenizer notify tokens.
|
||||
///
|
||||
/// See FTS5WrapperTokenizer.accept(token:flags:tokenCallback:)
|
||||
public typealias FTS5WrapperTokenCallback = (_ token: String, _ flags: FTS5TokenFlags) throws -> Void
|
||||
|
||||
/// A type that implements a custom tokenizer for the ``FTS5`` full-text engine
|
||||
/// by wrapping another tokenizer.
|
||||
///
|
||||
/// See [FTS5 Tokenizers](https://github.com/groue/GRDB.swift/blob/master/Documentation/FTS5Tokenizers.md)
|
||||
/// for more information.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Tokenizing Text
|
||||
///
|
||||
/// - ``accept(token:flags:for:tokenCallback:)``
|
||||
/// - ``FTS5WrapperTokenCallback``
|
||||
public protocol FTS5WrapperTokenizer: FTS5CustomTokenizer {
|
||||
/// The wrapped tokenizer
|
||||
var wrappedTokenizer: any FTS5Tokenizer { get }
|
||||
|
||||
/// Given a token produced by the wrapped tokenizer, notifies customized
|
||||
/// tokens to the `tokenCallback` function.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// func accept(
|
||||
/// token: String,
|
||||
/// flags: FTS5TokenFlags,
|
||||
/// for tokenization: FTS5Tokenization,
|
||||
/// tokenCallback: FTS5WrapperTokenCallback
|
||||
/// ) throws {
|
||||
/// // pass through:
|
||||
/// try tokenCallback(token, flags)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// When implementing the accept method, there are a two rules
|
||||
/// to observe:
|
||||
///
|
||||
/// 1. Errors thrown by the tokenCallback function must not be caught.
|
||||
///
|
||||
/// 2. The input `flags` should be given unmodified to the tokenCallback
|
||||
/// function, unless you union it with the ``FTS5TokenFlags/colocated`` flag
|
||||
/// when the tokenizer produces synonyms (see
|
||||
/// <https://www.sqlite.org/fts5.html#synonym_support>).
|
||||
///
|
||||
/// - parameters:
|
||||
/// - token: A token produced by the wrapped tokenizer
|
||||
/// - flags: Flags that tell SQLite how to register a token.
|
||||
/// - tokenization: The reason why FTS5 is requesting tokenization.
|
||||
/// - tokenCallback: The function to call for each customized token.
|
||||
func accept(
|
||||
token: String,
|
||||
flags: FTS5TokenFlags,
|
||||
for tokenization: FTS5Tokenization,
|
||||
tokenCallback: FTS5WrapperTokenCallback)
|
||||
throws
|
||||
}
|
||||
|
||||
private struct FTS5WrapperContext {
|
||||
let tokenizer: any FTS5WrapperTokenizer
|
||||
let context: UnsafeMutableRawPointer?
|
||||
let tokenization: FTS5Tokenization
|
||||
let tokenCallback: FTS5TokenCallback
|
||||
}
|
||||
|
||||
extension FTS5WrapperTokenizer {
|
||||
public func tokenize(
|
||||
context: UnsafeMutableRawPointer?,
|
||||
tokenization: FTS5Tokenization,
|
||||
pText: UnsafePointer<CChar>?,
|
||||
nText: CInt,
|
||||
tokenCallback: @escaping FTS5TokenCallback)
|
||||
-> CInt
|
||||
{
|
||||
// `tokenCallback` is @convention(c). This requires a little setup
|
||||
// in order to transfer context.
|
||||
var customContext = FTS5WrapperContext(
|
||||
tokenizer: self,
|
||||
context: context,
|
||||
tokenization: tokenization,
|
||||
tokenCallback: tokenCallback)
|
||||
return withUnsafeMutablePointer(to: &customContext) { customContextPointer in
|
||||
// Invoke wrappedTokenizer
|
||||
return wrappedTokenizer.tokenize(
|
||||
context: customContextPointer,
|
||||
tokenization: tokenization,
|
||||
pText: pText,
|
||||
nText: nText) { (customContextPointer, tokenFlags, pToken, nToken, iStart, iEnd) in
|
||||
|
||||
// Extract token produced by wrapped tokenizer
|
||||
guard let token = pToken.flatMap({ String(
|
||||
data: Data(
|
||||
bytesNoCopy: UnsafeMutableRawPointer(mutating: $0),
|
||||
count: Int(nToken),
|
||||
deallocator: .none),
|
||||
encoding: .utf8) })
|
||||
else {
|
||||
return SQLITE_OK // 0 // SQLITE_OK
|
||||
}
|
||||
|
||||
// Extract context
|
||||
let customContext = customContextPointer!.assumingMemoryBound(to: FTS5WrapperContext.self).pointee
|
||||
let tokenizer = customContext.tokenizer
|
||||
let context = customContext.context
|
||||
let tokenization = customContext.tokenization
|
||||
let tokenCallback = customContext.tokenCallback
|
||||
|
||||
// Process token produced by wrapped tokenizer
|
||||
do {
|
||||
try tokenizer.accept(
|
||||
token: token,
|
||||
flags: FTS5TokenFlags(rawValue: tokenFlags),
|
||||
for: tokenization,
|
||||
tokenCallback: { (token, flags) in
|
||||
// Turn token into bytes
|
||||
return try ContiguousArray(token.utf8).withUnsafeBufferPointer { buffer in
|
||||
guard let addr = buffer.baseAddress else {
|
||||
return
|
||||
}
|
||||
let pToken = UnsafeMutableRawPointer(mutating: addr)
|
||||
.assumingMemoryBound(to: CChar.self)
|
||||
let nToken = CInt(buffer.count)
|
||||
|
||||
// Inject token bytes into SQLite
|
||||
let code = tokenCallback(context, flags.rawValue, pToken, nToken, iStart, iEnd)
|
||||
guard code == SQLITE_OK else {
|
||||
throw DatabaseError(resultCode: code, message: "token callback failed")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return SQLITE_OK
|
||||
} catch let error as DatabaseError {
|
||||
return error.extendedResultCode.rawValue
|
||||
} catch {
|
||||
return SQLITE_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user