diff --git a/Sources/StructuredQueriesCore/Operators.swift b/Sources/StructuredQueriesCore/Operators.swift index 51a58461..779ac78e 100644 --- a/Sources/StructuredQueriesCore/Operators.swift +++ b/Sources/StructuredQueriesCore/Operators.swift @@ -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 { - BinaryOperator( - lhs: self, - operator: "MATCH", - rhs: "\(pattern)" - ) - } - /// A predicate expression from this string expression matched against another _via_ the `LIKE` /// operator given a prefix. /// diff --git a/Sources/StructuredQueriesCore/SQLite/FTS5.swift b/Sources/StructuredQueriesCore/SQLite/FTS5.swift new file mode 100644 index 00000000..b8998f5f --- /dev/null +++ b/Sources/StructuredQueriesCore/SQLite/FTS5.swift @@ -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 { + SQLQueryExpression( + """ + (\(QueryValue.self) MATCH \(bind: "\(pattern)")) + """ + ) + } + + /// An expression representing the search result's rank. + public var rank: some QueryExpression { + 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._ `""`. + /// - close: A closing delimiter denoting the end of a match, _e.g._, `""`. + /// - Returns: A string expression highlighting matches in this column. + public func highlight( + _ open: some StringProtocol, + _ close: some StringProtocol + ) -> some QueryExpression { + 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 { + 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._ `""`. + /// - close: A closing delimiter denoting the end of a match, _e.g._, `""`. + /// - 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 { + 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 { + SQLQueryExpression( + """ + SELECT "cid" FROM pragma_table_info(\(quote: Root.tableName, delimiter: .text)) \ + WHERE "name" = \(quote: name, delimiter: .text) + """ + ) + } +} diff --git a/Tests/StructuredQueriesTests/FTSTests.swift b/Tests/StructuredQueriesTests/FTSTests.swift new file mode 100644 index 00000000..3bad7dda --- /dev/null +++ b/Tests/StructuredQueriesTests/FTSTests.swift @@ -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: "" │ + │ ) │ + └────────────────────────────┘ + """ + } + } + + } +} diff --git a/Tests/StructuredQueriesTests/Support/Schema.swift b/Tests/StructuredQueriesTests/Support/Schema.swift index 596db2f8..360f4507 100644 --- a/Tests/StructuredQueriesTests/Support/Schema.swift +++ b/Tests/StructuredQueriesTests/Support/Schema.swift @@ -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 } @@ -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 {