Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 0 additions & 14 deletions Sources/StructuredQueriesCore/Operators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -730,20 +730,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: QueryValue) -> some QueryExpression<Bool> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this to requiring an FTS5 conformance is technically a breaking change, but it's not valid to call match outside of FTS, so I think it's fine while the library is still 0.x.

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
80 changes: 80 additions & 0 deletions Sources/StructuredQueriesCore/SQLite/FTS5.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/// 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 {
@available(*, deprecated, message: "Virtual tables are not 'rowid' tables")
public var rowid: some QueryExpression<Int> {
SQLQueryExpression(
"""
\(QueryValue.self)."rowid"
"""
)
}

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

/// 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 QueryExpression<String>) -> some QueryExpression<Bool> {
SQLQueryExpression(
"""
(\(QueryValue.self) MATCH \(pattern))
"""
)
}
}

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: String,
_ close: String
) -> some QueryExpression<String> {
SQLQueryExpression(
"""
highlight(\
\(Root.self), \
(\
SELECT "cid" FROM pragma_table_info(\(quote: Root.tableName, delimiter: .text)) \
WHERE "name" = \(quote: name, delimiter: .text)\
),
\(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 QueryExpression<String>) -> some QueryExpression<Bool> {
BinaryOperator(lhs: self, operator: "MATCH", rhs: pattern)
}
}
55 changes: 55 additions & 0 deletions Tests/StructuredQueriesTests/FTSTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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.highlight("**", "**")) }
) {
"""
SELECT highlight("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'title'),
'**', '**'), highlight("reminderTexts", (SELECT "cid" FROM pragma_table_info('reminderTexts') WHERE "name" = 'notes'),
'**', '**')
FROM "reminderTexts"
WHERE ("reminderTexts" MATCH 'take OR apple')
ORDER BY "reminderTexts"."rank"
"""
} results: {
"""
┌──────────────────────┬──────────────────────────┐
│ "Groceries" │ "Milk, 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 │
└────────────┴─────┘
"""
}
}
}
}
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