add swiftUI code

This commit is contained in:
zeus
2025-01-22 14:09:10 +08:00
parent 68e7b7347c
commit 8a99853829
2531 changed files with 486215 additions and 0 deletions
@@ -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