Skip to content

Commit eb8c86d

Browse files
authored
Full-text search (#120)
* Full-text search * wip * wip * Update FTS5.swift * wip * wip * wip * wip * wip * wip * wip * fix
1 parent 8e697e2 commit eb8c86d

File tree

4 files changed

+308
-18
lines changed

4 files changed

+308
-18
lines changed

Sources/StructuredQueriesCore/Operators.swift

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -733,24 +733,6 @@ extension QueryExpression where QueryValue == String {
733733
LikeOperator(string: self, pattern: "\(pattern)", escape: escape)
734734
}
735735

736-
/// A predicate expression from this string expression matched against another _via_ the `MATCH`
737-
/// operator.
738-
///
739-
/// ```swift
740-
/// Reminder.where { $0.title.match("get") }
741-
/// // SELECT … FROM "reminders" WHERE ("reminders"."title" MATCH 'get')
742-
/// ```
743-
///
744-
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
745-
/// - Returns: A predicate expression.
746-
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
747-
BinaryOperator(
748-
lhs: self,
749-
operator: "MATCH",
750-
rhs: "\(pattern)"
751-
)
752-
}
753-
754736
/// A predicate expression from this string expression matched against another _via_ the `LIKE`
755737
/// operator given a prefix.
756738
///
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import IssueReporting
2+
3+
/// A virtual table using the FTS5 extension.
4+
///
5+
/// Apply this protocol to a `@Table` declaration to introduce FTS5 helpers.
6+
public protocol FTS5: Table {}
7+
8+
extension TableDefinition where QueryValue: FTS5 {
9+
/// A predicate expression from this table matched against another _via_ the `MATCH` operator.
10+
///
11+
/// ```swift
12+
/// ReminderText.where { $0.match("get") }
13+
/// // SELECT … FROM "reminderTexts" WHERE ("reminderTexts" MATCH 'get')
14+
/// ```
15+
///
16+
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
17+
/// - Returns: A predicate expression.
18+
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
19+
SQLQueryExpression(
20+
"""
21+
(\(QueryValue.self) MATCH \(bind: "\(pattern)"))
22+
"""
23+
)
24+
}
25+
26+
/// An expression representing the search result's rank.
27+
public var rank: some QueryExpression<Double?> {
28+
SQLQueryExpression(
29+
"""
30+
\(QueryValue.self)."rank"
31+
"""
32+
)
33+
}
34+
}
35+
36+
extension TableColumnExpression where Root: FTS5 {
37+
/// A string expression highlighting matches in this column using the given delimiters.
38+
///
39+
/// - Parameters:
40+
/// - open: An opening delimiter denoting the beginning of a match, _e.g._ `"<b>"`.
41+
/// - close: A closing delimiter denoting the end of a match, _e.g._, `"</b>"`.
42+
/// - Returns: A string expression highlighting matches in this column.
43+
public func highlight(
44+
_ open: some StringProtocol,
45+
_ close: some StringProtocol
46+
) -> some QueryExpression<String> {
47+
SQLQueryExpression(
48+
"""
49+
highlight(\
50+
\(Root.self), \
51+
(\(cid)),
52+
\(quote: "\(open)", delimiter: .text), \
53+
\(quote: "\(close)", delimiter: .text)\
54+
)
55+
"""
56+
)
57+
}
58+
59+
/// A predicate expression from this column matched against another _via_ the `MATCH` operator.
60+
///
61+
/// ```swift
62+
/// ReminderText.where { $0.title.match("get") }
63+
/// // SELECT … FROM "reminderTexts" WHERE ("reminderTexts"."title" MATCH 'get')
64+
/// ```
65+
///
66+
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
67+
/// - Returns: A predicate expression.
68+
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
69+
Root.columns.match("\(name):\(pattern.quoted(.identifier))")
70+
}
71+
72+
/// A string expression highlighting matches in text fragments of this column using the given
73+
/// delimiters.
74+
///
75+
/// - Parameters:
76+
/// - open: An opening delimiter denoting the beginning of a match, _e.g._ `"<b>"`.
77+
/// - close: A closing delimiter denoting the end of a match, _e.g._, `"</b>"`.
78+
/// - ellipsis: Text indicating a truncation of text in the column.
79+
/// - tokens: The maximum number of tokens in the returned text.
80+
/// - Returns: A string expression highlighting matches in this column.
81+
public func snippet(
82+
_ open: some StringProtocol,
83+
_ close: some StringProtocol,
84+
_ ellipsis: some StringProtocol,
85+
_ tokens: Int
86+
) -> some QueryExpression<String> {
87+
SQLQueryExpression(
88+
"""
89+
snippet(\
90+
\(Root.self), \
91+
(\(cid)),
92+
\(quote: "\(open)", delimiter: .text), \
93+
\(quote: "\(close)", delimiter: .text), \
94+
\(quote: "\(ellipsis)", delimiter: .text), \
95+
\(raw: tokens)\
96+
)
97+
"""
98+
)
99+
}
100+
}
101+
102+
extension TableColumnExpression {
103+
fileprivate var cid: some Statement<Int> {
104+
SQLQueryExpression(
105+
"""
106+
SELECT "cid" FROM pragma_table_info(\(quote: Root.tableName, delimiter: .text)) \
107+
WHERE "name" = \(quote: name, delimiter: .text)
108+
"""
109+
)
110+
}
111+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Foundation
2+
import InlineSnapshotTesting
3+
import StructuredQueries
4+
import StructuredQueriesTestSupport
5+
import Testing
6+
7+
extension SnapshotTests {
8+
@Suite struct FTSTests {
9+
@Test func basics() {
10+
assertQuery(
11+
ReminderText
12+
.where { $0.match("take OR apple") }
13+
.order(by: \.rank)
14+
.select { ($0.title.highlight("**", "**"), $0.notes.snippet("**", "**", "...", 10)) }
15+
) {
16+
"""
17+
SELECT highlight("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'title'),
18+
'**', '**'), snippet("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'notes'),
19+
'**', '**', '...', 10)
20+
FROM "reminderTexts"
21+
WHERE ("reminderTexts" MATCH 'take OR apple')
22+
ORDER BY "reminderTexts"."rank"
23+
"""
24+
} results: {
25+
"""
26+
┌──────────────────────┬───────────────────────┐
27+
"Groceries""...Eggs, **Apple**s"
28+
"**Take** out trash"""
29+
"**Take** a walk"""
30+
└──────────────────────┴───────────────────────┘
31+
"""
32+
}
33+
}
34+
35+
@Test func unranked() {
36+
assertQuery(
37+
ReminderText
38+
.select { ($0.listTitle, $0.rank) }
39+
.limit(1)
40+
) {
41+
"""
42+
SELECT "reminderTexts"."listTitle", "reminderTexts"."rank"
43+
FROM "reminderTexts"
44+
LIMIT 1
45+
"""
46+
} results: {
47+
"""
48+
┌────────────┬─────┐
49+
"Personal" │ nil │
50+
└────────────┴─────┘
51+
"""
52+
}
53+
}
54+
55+
@Test func columnMatch() {
56+
assertQuery(
57+
ReminderText
58+
.where { $0.title.match("take") }
59+
) {
60+
"""
61+
SELECT "reminderTexts"."reminderID", "reminderTexts"."title", "reminderTexts"."notes", "reminderTexts"."listID", "reminderTexts"."listTitle", "reminderTexts"."tags"
62+
FROM "reminderTexts"
63+
WHERE ("reminderTexts" MATCH 'title:"take"')
64+
"""
65+
} results: {
66+
"""
67+
┌────────────────────────────┐
68+
│ ReminderText( │
69+
│ reminderID: 4, │
70+
│ title: "Take a walk", │
71+
│ notes: "", │
72+
│ listID: 1, │
73+
│ listTitle: "Personal", │
74+
│ tags: "car kids"
75+
│ ) │
76+
├────────────────────────────┤
77+
│ ReminderText( │
78+
│ reminderID: 8, │
79+
│ title: "Take out trash", │
80+
│ notes: "", │
81+
│ listID: 2, │
82+
│ listTitle: "Family", │
83+
│ tags: ""
84+
│ ) │
85+
└────────────────────────────┘
86+
"""
87+
}
88+
}
89+
90+
}
91+
}

Tests/StructuredQueriesTests/Support/Schema.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,21 @@ struct ReminderTag: Equatable {
7373
var title = ""
7474
}
7575

76+
@Table
77+
struct ReminderText: FTS5 {
78+
let reminderID: Reminder.ID
79+
let title: String
80+
let notes: String
81+
let listID: RemindersList.ID
82+
let listTitle: String
83+
let tags: String
84+
}
85+
7686
extension Database {
7787
static func `default`() throws -> Database {
7888
let db = try Database()
7989
try db.migrate()
90+
try db.installTriggers()
8091
try db.seedDatabase()
8192
return db
8293
}
@@ -152,6 +163,101 @@ extension Database {
152163
)
153164
"""
154165
)
166+
try execute(
167+
"""
168+
CREATE VIRTUAL TABLE "reminderTexts" USING fts5(
169+
"reminderID" UNINDEXED,
170+
"title",
171+
"notes",
172+
"listID" UNINDEXED,
173+
"listTitle",
174+
"tags",
175+
tokenize = 'trigram'
176+
)
177+
"""
178+
)
179+
}
180+
181+
func installTriggers() throws {
182+
try execute(
183+
Reminder.createTemporaryTrigger(
184+
after: .insert { new in
185+
ReminderText.insert {
186+
($0.reminderID, $0.title, $0.notes, $0.listID, $0.listTitle, $0.tags)
187+
} select: {
188+
Reminder
189+
.find(new.id)
190+
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
191+
.select { ($0.id, $0.title, $0.notes, $1.id, $1.title, "") }
192+
}
193+
}
194+
)
195+
)
196+
try execute(
197+
Reminder.createTemporaryTrigger(
198+
after: .update {
199+
($0.title, $0.notes, $0.remindersListID)
200+
} forEachRow: { _, new in
201+
ReminderText
202+
.where { $0.reminderID.eq(new.id) }
203+
.update {
204+
$0.title = new.title
205+
$0.notes = new.notes
206+
$0.listID = new.remindersListID
207+
}
208+
}
209+
)
210+
)
211+
try execute(
212+
Reminder.createTemporaryTrigger(
213+
after: .delete { old in
214+
ReminderText
215+
.where { $0.reminderID.eq(old.id) }
216+
.delete()
217+
}
218+
)
219+
)
220+
try execute(
221+
RemindersList.createTemporaryTrigger(
222+
after: .update {
223+
$0.title
224+
} forEachRow: { _, new in
225+
ReminderText
226+
.where { $0.listID.eq(new.id) }
227+
.update { $0.listTitle = new.title }
228+
}
229+
)
230+
)
231+
try execute(
232+
ReminderTag.createTemporaryTrigger(
233+
after: .insert { new in
234+
ReminderText
235+
.where { $0.reminderID.eq(new.reminderID) }
236+
.update {
237+
$0.tags =
238+
ReminderTag
239+
.where { $0.reminderID.eq(new.reminderID) }
240+
.join(Tag.all) { $0.tagID.eq($1.id) }
241+
.select { $1.title.groupConcat(" ") ?? "" }
242+
}
243+
}
244+
)
245+
)
246+
try execute(
247+
ReminderTag.createTemporaryTrigger(
248+
after: .delete { old in
249+
ReminderText
250+
.where { $0.reminderID.eq(old.reminderID) }
251+
.update {
252+
$0.tags =
253+
ReminderTag
254+
.where { $0.reminderID.eq(old.reminderID) }
255+
.join(Tag.all) { $0.tagID.eq($1.id) }
256+
.select { $1.title.groupConcat(" ") ?? "" }
257+
}
258+
}
259+
)
260+
)
155261
}
156262

157263
func seedDatabase() throws {

0 commit comments

Comments
 (0)