/
FTS5.swift
333 lines (294 loc) · 13.2 KB
/
FTS5.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
#if SQLITE_ENABLE_FTS5
/// FTS5 lets you define "fts5" virtual tables.
///
/// // CREATE VIRTUAL TABLE document USING fts5(content)
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.column("content")
/// }
///
/// See https://www.sqlite.org/fts5.html
public struct FTS5 : VirtualTableModule {
/// Creates a FTS5 module suitable for the Database
/// `create(virtualTable:using:)` method.
///
/// // CREATE VIRTUAL TABLE document USING fts5(content)
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.column("content")
/// }
///
/// See https://www.sqlite.org/fts5.html
public init() {
}
// MARK: - VirtualTableModule Adoption
/// The virtual table module name
public let moduleName = "fts5"
/// Don't use this method.
public func makeTableDefinition() -> FTS5TableDefinition {
return FTS5TableDefinition()
}
/// 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 {
arguments.append("tokenize=\(tokenizer.components.joined(separator: " ").sqlExpression.sql)")
}
switch definition.contentMode {
case .raw(let content, let contentRowID):
if let content = content {
arguments.append("content=\(content.sqlExpression.sql)")
}
if let contentRowID = contentRowID {
arguments.append("content_rowid=\(contentRowID.sqlExpression.sql)")
}
case .synchronized(let contentTable):
arguments.append("content=\(contentTable.sqlExpression.sql)")
if let rowIDColumn = try db.primaryKey(contentTable).rowIDColumn {
arguments.append("content_rowid=\(rowIDColumn.sqlExpression.sql)")
}
}
if let prefixes = definition.prefixes {
arguments.append("prefix=\(prefixes.sorted().map { "\($0)" }.joined(separator: " ").sqlExpression.sql)")
}
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 { $0.name }
let ftsColumns = (["rowid"] + indexedColumns)
.map { $0.quotedDatabaseIdentifier }
.joined(separator: ", ")
let newContentColumns = ([rowIDColumn] + indexedColumns)
.map { "new.\($0.quotedDatabaseIdentifier)" }
.joined(separator: ", ")
let oldContentColumns = ([rowIDColumn] + indexedColumns)
.map { "old.\($0.quotedDatabaseIdentifier)" }
.joined(separator: ", ")
try db.execute("""
CREATE TRIGGER \("__\(tableName)_ai".quotedDatabaseIdentifier) AFTER INSERT ON \(content) BEGIN
INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES (\(newContentColumns));
END;
CREATE TRIGGER \("__\(tableName)_ad".quotedDatabaseIdentifier) AFTER DELETE ON \(content) BEGIN
INSERT INTO \(ftsTable)(\(ftsTable), \(ftsColumns)) VALUES('delete', \(oldContentColumns));
END;
CREATE TRIGGER \("__\(tableName)_au".quotedDatabaseIdentifier) AFTER UPDATE ON \(content) BEGIN
INSERT INTO \(ftsTable)(\(ftsTable), \(ftsColumns)) VALUES('delete', \(oldContentColumns));
INSERT INTO \(ftsTable)(\(ftsColumns)) VALUES (\(newContentColumns));
END;
""")
// https://sqlite.org/fts5.html#the_rebuild_command
try db.execute("INSERT INTO \(ftsTable)(\(ftsTable)) VALUES('rebuild')")
}
}
static func api(_ db: Database) -> UnsafePointer<fts5_api> {
let sqliteConnection = db.sqliteConnection
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: Int8.self, capacity: type.utf8CodeUnitCount) { typePointer in
_ = sqlite3_bind_pointer(statement, 1, &api, typePointer, nil)
}
sqlite3_step(statement)
guard let result = api else {
fatalError("FTS5 is not available")
}
return result
}
}
/// The FTS5TableDefinition class lets you define columns of a FTS5 virtual table.
///
/// You don't create instances of this class. Instead, you use the Database
/// `create(virtualTable:using:)` method:
///
/// try db.create(virtualTable: "document", using: FTS5()) { t in // t is FTS5TableDefinition
/// t.column("content")
/// }
///
/// See https://www.sqlite.org/fts5.html
public final class FTS5TableDefinition {
enum ContentMode {
case raw(content: String?, contentRowID: String?)
case synchronized(contentTable: String)
}
fileprivate var columns: [FTS5ColumnDefinition] = []
fileprivate var contentMode: ContentMode = .raw(content: nil, contentRowID: nil)
/// The virtual table tokenizer
///
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.tokenizer = .porter()
/// }
///
/// See 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.
///
/// See 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.
///
/// See https://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)
}
}
}
/// Support for the FTS5 `prefix` option
///
/// See https://www.sqlite.org/fts5.html#prefix_indexes
public var prefixes: Set<Int>?
/// Support for the FTS5 `columnsize` option
///
/// https://www.sqlite.org/fts5.html#the_columnsize_option
public var columnSize: Int?
/// Support for the FTS5 `detail` option
///
/// https://www.sqlite.org/fts5.html#the_detail_option
public var detail: String?
/// Appends a table column.
///
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.column("content")
/// }
///
/// - parameter name: the column name.
@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.
///
/// See https://sqlite.org/fts5.html#external_content_tables
public func synchronize(withTable tableName: String) {
contentMode = .synchronized(contentTable: tableName)
}
}
/// The FTS5ColumnDefinition class lets you refine a column of an FTS5
/// virtual table.
///
/// You get instances of this class when you create an FTS5 table:
///
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.column("content") // FTS5ColumnDefinition
/// }
///
/// See 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.
///
/// try db.create(virtualTable: "document", using: FTS5()) { t in
/// t.column("a")
/// t.column("b").notIndexed()
/// }
///
/// See 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
}
}
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("""
DROP TRIGGER IF EXISTS \("__\(tableName)_ai".quotedDatabaseIdentifier);
DROP TRIGGER IF EXISTS \("__\(tableName)_ad".quotedDatabaseIdentifier);
DROP TRIGGER IF EXISTS \("__\(tableName)_au".quotedDatabaseIdentifier);
""")
}
}
#endif