Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions Sources/StructuredQueriesCore/Operators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -733,24 +733,6 @@ extension QueryExpression where QueryValue == String {
LikeOperator(string: self, pattern: "\(pattern)", escape: escape)
}

/// A predicate expression from this string expression matched against another _via_ the `MATCH`
/// operator.
///
/// ```swift
/// Reminder.where { $0.title.match("get") }
/// // SELECT … FROM "reminders" WHERE ("reminders"."title" MATCH 'get')
/// ```
///
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
/// - Returns: A predicate expression.
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
BinaryOperator(
lhs: self,
operator: "MATCH",
rhs: "\(pattern)"
)
}

/// A predicate expression from this string expression matched against another _via_ the `LIKE`
/// operator given a prefix.
///
Expand Down
111 changes: 111 additions & 0 deletions Sources/StructuredQueriesCore/SQLite/FTS5.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import IssueReporting

/// A virtual table using the FTS5 extension.
///
/// Apply this protocol to a `@Table` declaration to introduce FTS5 helpers.
public protocol FTS5: Table {}

extension TableDefinition where QueryValue: FTS5 {
/// A predicate expression from this table matched against another _via_ the `MATCH` operator.
///
/// ```swift
/// ReminderText.where { $0.match("get") }
/// // SELECT … FROM "reminderTexts" WHERE ("reminderTexts" MATCH 'get')
/// ```
///
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
/// - Returns: A predicate expression.
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
SQLQueryExpression(
"""
(\(QueryValue.self) MATCH \(bind: "\(pattern)"))
"""
)
}

/// An expression representing the search result's rank.
public var rank: some QueryExpression<Double?> {
SQLQueryExpression(
"""
\(QueryValue.self)."rank"
"""
)
}
}

extension TableColumnExpression where Root: FTS5 {
/// A string expression highlighting matches in this column using the given delimiters.
///
/// - Parameters:
/// - open: An opening delimiter denoting the beginning of a match, _e.g._ `"<b>"`.
/// - close: A closing delimiter denoting the end of a match, _e.g._, `"</b>"`.
/// - Returns: A string expression highlighting matches in this column.
public func highlight(
_ open: some StringProtocol,
_ close: some StringProtocol
) -> some QueryExpression<String> {
SQLQueryExpression(
"""
highlight(\
\(Root.self), \
(\(cid)),
\(quote: "\(open)", delimiter: .text), \
\(quote: "\(close)", delimiter: .text)\
)
"""
)
}

/// A predicate expression from this column matched against another _via_ the `MATCH` operator.
///
/// ```swift
/// ReminderText.where { $0.title.match("get") }
/// // SELECT … FROM "reminderTexts" WHERE ("reminderTexts"."title" MATCH 'get')
/// ```
///
/// - Parameter pattern: A string expression describing the `MATCH` pattern.
/// - Returns: A predicate expression.
public func match(_ pattern: some StringProtocol) -> some QueryExpression<Bool> {
Root.columns.match("\(name):\(pattern.quoted(.identifier))")
}

/// A string expression highlighting matches in text fragments of this column using the given
/// delimiters.
///
/// - Parameters:
/// - open: An opening delimiter denoting the beginning of a match, _e.g._ `"<b>"`.
/// - close: A closing delimiter denoting the end of a match, _e.g._, `"</b>"`.
/// - ellipsis: Text indicating a truncation of text in the column.
/// - tokens: The maximum number of tokens in the returned text.
/// - Returns: A string expression highlighting matches in this column.
public func snippet(
_ open: some StringProtocol,
_ close: some StringProtocol,
_ ellipsis: some StringProtocol,
_ tokens: Int
) -> some QueryExpression<String> {
SQLQueryExpression(
"""
snippet(\
\(Root.self), \
(\(cid)),
\(quote: "\(open)", delimiter: .text), \
\(quote: "\(close)", delimiter: .text), \
\(quote: "\(ellipsis)", delimiter: .text), \
\(raw: tokens)\
)
"""
)
}
}

extension TableColumnExpression {
fileprivate var cid: some Statement<Int> {
SQLQueryExpression(
"""
SELECT "cid" FROM pragma_table_info(\(quote: Root.tableName, delimiter: .text)) \
WHERE "name" = \(quote: name, delimiter: .text)
"""
)
}
}
91 changes: 91 additions & 0 deletions Tests/StructuredQueriesTests/FTSTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import InlineSnapshotTesting
import StructuredQueries
import StructuredQueriesTestSupport
import Testing

extension SnapshotTests {
@Suite struct FTSTests {
@Test func basics() {
assertQuery(
ReminderText
.where { $0.match("take OR apple") }
.order(by: \.rank)
.select { ($0.title.highlight("**", "**"), $0.notes.snippet("**", "**", "...", 10)) }
) {
"""
SELECT highlight("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'title'),
'**', '**'), snippet("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'notes'),
'**', '**', '...', 10)
FROM "reminderTexts"
WHERE ("reminderTexts" MATCH 'take OR apple')
ORDER BY "reminderTexts"."rank"
"""
} results: {
"""
┌──────────────────────┬───────────────────────┐
│ "Groceries" │ "...Eggs, **Apple**s" │
│ "**Take** out trash" │ "" │
│ "**Take** a walk" │ "" │
└──────────────────────┴───────────────────────┘
"""
}
}

@Test func unranked() {
assertQuery(
ReminderText
.select { ($0.listTitle, $0.rank) }
.limit(1)
) {
"""
SELECT "reminderTexts"."listTitle", "reminderTexts"."rank"
FROM "reminderTexts"
LIMIT 1
"""
} results: {
"""
┌────────────┬─────┐
│ "Personal" │ nil │
└────────────┴─────┘
"""
}
}

@Test func columnMatch() {
assertQuery(
ReminderText
.where { $0.title.match("take") }
) {
"""
SELECT "reminderTexts"."reminderID", "reminderTexts"."title", "reminderTexts"."notes", "reminderTexts"."listID", "reminderTexts"."listTitle", "reminderTexts"."tags"
FROM "reminderTexts"
WHERE ("reminderTexts" MATCH 'title:"take"')
"""
} results: {
"""
┌────────────────────────────┐
│ ReminderText( │
│ reminderID: 4, │
│ title: "Take a walk", │
│ notes: "", │
│ listID: 1, │
│ listTitle: "Personal", │
│ tags: "car kids" │
│ ) │
├────────────────────────────┤
│ ReminderText( │
│ reminderID: 8, │
│ title: "Take out trash", │
│ notes: "", │
│ listID: 2, │
│ listTitle: "Family", │
│ tags: "" │
│ ) │
└────────────────────────────┘
"""
}
}

}
}
106 changes: 106 additions & 0 deletions Tests/StructuredQueriesTests/Support/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,21 @@ struct ReminderTag: Equatable {
var title = ""
}

@Table
struct ReminderText: FTS5 {
let reminderID: Reminder.ID
let title: String
let notes: String
let listID: RemindersList.ID
let listTitle: String
let tags: String
}

extension Database {
static func `default`() throws -> Database {
let db = try Database()
try db.migrate()
try db.installTriggers()
try db.seedDatabase()
return db
}
Expand Down Expand Up @@ -152,6 +163,101 @@ extension Database {
)
"""
)
try execute(
"""
CREATE VIRTUAL TABLE "reminderTexts" USING fts5(
"reminderID" UNINDEXED,
"title",
"notes",
"listID" UNINDEXED,
"listTitle",
"tags",
tokenize = 'trigram'
)
"""
)
}

func installTriggers() throws {
try execute(
Reminder.createTemporaryTrigger(
after: .insert { new in
ReminderText.insert {
($0.reminderID, $0.title, $0.notes, $0.listID, $0.listTitle, $0.tags)
} select: {
Reminder
.find(new.id)
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.select { ($0.id, $0.title, $0.notes, $1.id, $1.title, "") }
}
}
)
)
try execute(
Reminder.createTemporaryTrigger(
after: .update {
($0.title, $0.notes, $0.remindersListID)
} forEachRow: { _, new in
ReminderText
.where { $0.reminderID.eq(new.id) }
.update {
$0.title = new.title
$0.notes = new.notes
$0.listID = new.remindersListID
}
}
)
)
try execute(
Reminder.createTemporaryTrigger(
after: .delete { old in
ReminderText
.where { $0.reminderID.eq(old.id) }
.delete()
}
)
)
try execute(
RemindersList.createTemporaryTrigger(
after: .update {
$0.title
} forEachRow: { _, new in
ReminderText
.where { $0.listID.eq(new.id) }
.update { $0.listTitle = new.title }
}
)
)
try execute(
ReminderTag.createTemporaryTrigger(
after: .insert { new in
ReminderText
.where { $0.reminderID.eq(new.reminderID) }
.update {
$0.tags =
ReminderTag
.where { $0.reminderID.eq(new.reminderID) }
.join(Tag.all) { $0.tagID.eq($1.id) }
.select { $1.title.groupConcat(" ") ?? "" }
}
}
)
)
try execute(
ReminderTag.createTemporaryTrigger(
after: .delete { old in
ReminderText
.where { $0.reminderID.eq(old.reminderID) }
.update {
$0.tags =
ReminderTag
.where { $0.reminderID.eq(old.reminderID) }
.join(Tag.all) { $0.tagID.eq($1.id) }
.select { $1.title.groupConcat(" ") ?? "" }
}
}
)
)
}

func seedDatabase() throws {
Expand Down