Skip to content

feat: Add Full-Text Search (FTS) setup and search functionality #36

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
20 changes: 18 additions & 2 deletions Demo/PowerSyncExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
objects = {

/* Begin PBXBuildFile section */
4276C4302DA67C2D0029D27D /* FtsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C42F2DA67C2D0029D27D /* FtsSetup.swift */; };
4276C4322DA6800A0029D27D /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4312DA6800A0029D27D /* SearchResultItem.swift */; };
4276C4342DA6805F0029D27D /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4332DA6805F0029D27D /* SearchResultRow.swift */; };
4276C4362DA680D90029D27D /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4352DA680D90029D27D /* SearchScreen.swift */; };
6A4AD3852B9EE763005CBFD4 /* SupabaseConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */; };
6A4AD3892B9EEB21005CBFD4 /* _Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */; };
6A4AD3902B9EF775005CBFD4 /* ErrorText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD38F2B9EF775005CBFD4 /* ErrorText.swift */; };
Expand Down Expand Up @@ -57,6 +61,10 @@

/* Begin PBXFileReference section */
18CC627A2CC7A8B5009F7CDE /* powersync-kotlin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "powersync-kotlin"; path = "../powersync-kotlin"; sourceTree = SOURCE_ROOT; };
4276C42F2DA67C2D0029D27D /* FtsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FtsSetup.swift; sourceTree = "<group>"; };
4276C4312DA6800A0029D27D /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
4276C4332DA6805F0029D27D /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = "<group>"; };
4276C4352DA680D90029D27D /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseConnector.swift; sourceTree = "<group>"; };
6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _Secrets.swift; sourceTree = "<group>"; };
6A4AD38F2B9EF775005CBFD4 /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -197,6 +205,7 @@
B65C4D6C2C60D38B00176007 /* HomeScreen.swift */,
B65C4D702C60D7D800176007 /* SignUpScreen.swift */,
B65C4D722C60D7EB00176007 /* TodosScreen.swift */,
4276C4352DA680D90029D27D /* SearchScreen.swift */,
);
path = Screens;
sourceTree = "<group>";
Expand All @@ -211,6 +220,7 @@
B666585E2C62115300159A81 /* ListRow.swift */,
B66658602C62179E00159A81 /* ListView.swift */,
6ABD787F2B9F2F1300558A41 /* AddListView.swift */,
4276C4332DA6805F0029D27D /* SearchResultRow.swift */,
);
path = Components;
sourceTree = "<group>";
Expand All @@ -223,6 +233,8 @@
6ABD78772B9F2D2800558A41 /* Schema.swift */,
B66658642C62314B00159A81 /* Lists.swift */,
B66658662C62315400159A81 /* Todos.swift */,
4276C42F2DA67C2D0029D27D /* FtsSetup.swift */,
4276C4312DA6800A0029D27D /* SearchResultItem.swift */,
);
path = PowerSync;
sourceTree = "<group>";
Expand Down Expand Up @@ -552,12 +564,16 @@
B65C4D712C60D7D800176007 /* SignUpScreen.swift in Sources */,
B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */,
6ABD786B2B9F2C1500558A41 /* TodoListView.swift in Sources */,
4276C4302DA67C2D0029D27D /* FtsSetup.swift in Sources */,
4276C4322DA6800A0029D27D /* SearchResultItem.swift in Sources */,
6A4AD3852B9EE763005CBFD4 /* SupabaseConnector.swift in Sources */,
B65C4D732C60D7EB00176007 /* TodosScreen.swift in Sources */,
6ABD787A2B9F2D8300558A41 /* TodoListRow.swift in Sources */,
B66658652C62314B00159A81 /* Lists.swift in Sources */,
6A4AD3902B9EF775005CBFD4 /* ErrorText.swift in Sources */,
4276C4362DA680D90029D27D /* SearchScreen.swift in Sources */,
B66658672C62315400159A81 /* Todos.swift in Sources */,
4276C4342DA6805F0029D27D /* SearchResultRow.swift in Sources */,
6ABD78672B9F2B4800558A41 /* RootView.swift in Sources */,
B66658612C62179E00159A81 /* ListView.swift in Sources */,
6ABD78782B9F2D2800558A41 /* Schema.swift in Sources */,
Expand Down Expand Up @@ -705,7 +721,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\"";
DEVELOPMENT_TEAM = 6WA62GTJNA;
DEVELOPMENT_TEAM = Y9N25KJUCF;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
Expand Down Expand Up @@ -742,7 +758,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\"";
DEVELOPMENT_TEAM = 6WA62GTJNA;
DEVELOPMENT_TEAM = Y9N25KJUCF;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
57 changes: 57 additions & 0 deletions Demo/PowerSyncExample/Components/SearchResultRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// SearchResultRow.swift
// PowerSyncExample
//
// Created by Wade Morris on 4/9/25.
//

import SwiftUI

struct SearchResultRow: View {
let item: SearchResultItem

var body: some View {
HStack {

Image(systemName: item.type == .list ? "list.bullet" : "checkmark.circle")
.foregroundColor(.secondary)

if let list = item.listContent {
Text(list.name)
} else if let todo = item.todo {
Text(todo.description)
.strikethrough(todo.isComplete, color: .secondary)
.foregroundColor(todo.isComplete ? .secondary : .primary)
} else {
Text("Unknown item")
}

Spacer()

Image(systemName: "chevron.right")
.font(.caption.weight(.bold))
.foregroundColor(.secondary.opacity(0.5))
}
.contentShape(Rectangle())
}
}

#Preview {
List {
SearchResultRow(item: SearchResultItem(
id: UUID().uuidString,
type: .list,
content: ListContent(id: UUID().uuidString, name: "Groceries", createdAt: "now", ownerId: "user1")
))
SearchResultRow(item: SearchResultItem(
id: UUID().uuidString,
type: .todo,
content: Todo(id: UUID().uuidString, listId: "list1", description: "Buy milk", isComplete: false)
))
SearchResultRow(item: SearchResultItem(
id: UUID().uuidString,
type: .todo,
content: Todo(id: UUID().uuidString, listId: "list1", description: "Walk the dog", isComplete: true)
))
}
}
1 change: 1 addition & 0 deletions Demo/PowerSyncExample/Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enum Route: Hashable {
case home
case signIn
case signUp
case search
}

@Observable
Expand Down
184 changes: 184 additions & 0 deletions Demo/PowerSyncExample/PowerSync/FtsSetup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//
// FtsSetup.swift
// PowerSyncExample
//
// Created by Wade Morris on 4/9/25.
//

import Foundation
import PowerSync

enum ExtractType {
case columnOnly
case columnInOperation
}

/// Generates SQL JSON extract expressions for FTS triggers.
///
/// - Parameters:
/// - type: The type of extraction needed (`columnOnly` or `columnInOperation`).
/// - sourceColumn: The JSON source column (e.g., `'data'`, `'NEW.data'`).
/// - columns: The list of column names to extract.
/// - Returns: A comma-separated string of SQL expressions.
func generateJsonExtracts(type: ExtractType, sourceColumn: String, columns: [String]) -> String {
func createExtract(jsonSource: String, columnName: String) -> String {
return "json_extract(\(jsonSource), '$.\"\(columnName)\"')"
}

func generateSingleColumnSql(columnName: String) -> String {
switch type {
case .columnOnly:
return createExtract(jsonSource: sourceColumn, columnName: columnName)
case .columnInOperation:
return "\"\(columnName)\" = \(createExtract(jsonSource: sourceColumn, columnName: columnName))"
}
}

return columns.map(generateSingleColumnSql).joined(separator: ", ")
}

/// Generates the SQL statements required to set up an FTS5 virtual table
/// and corresponding triggers for a given PowerSync table.
///
///
/// - Parameters:
/// - tableName: The public name of the table to index (e.g., "lists", "todos").
/// - columns: The list of column names within the table to include in the FTS index.
/// - schema: The PowerSync `Schema` object to find the internal table name.
/// - tokenizationMethod: The FTS5 tokenization method (e.g., "porter unicode61", "unicode61").
/// - Returns: An array of SQL statements to be executed, or `nil` if the table is not found in the schema.
func getFtsSetupSqlStatements(
tableName: String,
columns: [String],
schema: Schema,
tokenizationMethod: String = "unicode61"
) -> [String]? {

guard let internalName = schema.tables.first(where: { $0.name == tableName })?.internalName else {
print("Table '\(tableName)' not found in schema. Skipping FTS setup for this table.")
return nil
}

let ftsTableName = "fts_\(tableName)"

let stringColumnsForCreate = columns.map { "\"\($0)\"" }.joined(separator: ", ")

let stringColumnsForInsertList = columns.map { "\"\($0)\"" }.joined(separator: ", ")

var sqlStatements: [String] = []

// 1. Create the FTS5 Virtual Table
sqlStatements.append("""
CREATE VIRTUAL TABLE IF NOT EXISTS \(ftsTableName)
USING fts5(id UNINDEXED, \(stringColumnsForCreate), tokenize='\(tokenizationMethod)');
""")

// 2. Copy existing data from the main table to the FTS table
sqlStatements.append("""
INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList))
SELECT rowid, id, \(generateJsonExtracts(type: .columnOnly, sourceColumn: "data", columns: columns))
FROM \(internalName);
""")

// 3. Create INSERT Trigger
sqlStatements.append("""
CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_\(tableName) AFTER INSERT ON \(internalName)
BEGIN
INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList))
VALUES (
NEW.rowid,
NEW.id,
\(generateJsonExtracts(type: .columnOnly, sourceColumn: "NEW.data", columns: columns))
);
END;
""")

// 4. Create UPDATE Trigger
sqlStatements.append("""
CREATE TRIGGER IF NOT EXISTS fts_update_trigger_\(tableName) AFTER UPDATE ON \(internalName)
BEGIN
UPDATE \(ftsTableName)
SET \(generateJsonExtracts(type: .columnInOperation, sourceColumn: "NEW.data", columns: columns))
WHERE rowid = NEW.rowid;
END;
""")

// 5. Create DELETE Trigger
sqlStatements.append("""
CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_\(tableName) AFTER DELETE ON \(internalName)
BEGIN
DELETE FROM \(ftsTableName) WHERE rowid = OLD.rowid;
END;
""")

return sqlStatements
}


/// Configures Full-Text Search (FTS) tables and triggers for specified tables
/// within the PowerSync database. Call this function during database initialization.
///
/// Executes all generated SQL within a single transaction.
///
/// - Parameters:
/// - db: The initialized `PowerSyncDatabaseProtocol` instance.
/// - schema: The `Schema` instance matching the database.
/// - Throws: An error if the database transaction fails.
func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws {
let ftsCheckTable = "fts_\(LISTS_TABLE)"
let checkSql = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"

do {
let existingTable: String? = try await db.getOptional(sql: checkSql, parameters: [ftsCheckTable]) { cursor in
try cursor.getString(name: "name")
}

if existingTable != nil {
print("[FTS] FTS table '\(ftsCheckTable)' already exists. Skipping setup.")
return
}
} catch {
print("[FTS] Failed to check for existing FTS tables: \(error.localizedDescription). Proceeding with setup attempt.")
}
print("[FTS] Starting FTS configuration...")
var allSqlStatements: [String] = []

if let listStatements = getFtsSetupSqlStatements(
tableName: LISTS_TABLE,
columns: ["name"],
schema: schema,
tokenizationMethod: "porter unicode61"
) {
print("[FTS] Generated \(listStatements.count) SQL statements for '\(LISTS_TABLE)' table.")
allSqlStatements.append(contentsOf: listStatements)
}

if let todoStatements = getFtsSetupSqlStatements(
tableName: TODOS_TABLE,
columns: ["description"],
schema: schema
) {
print("[FTS] Generated \(todoStatements.count) SQL statements for '\(TODOS_TABLE)' table.")
allSqlStatements.append(contentsOf: todoStatements)
}

// --- Execute all generated SQL statements ---

if !allSqlStatements.isEmpty {
do {
print("[FTS] Executing \(allSqlStatements.count) SQL statements in a transaction...")
_ = try await db.writeTransaction { transaction in
for sql in allSqlStatements {
print("[FTS] Executing SQL:\n\(sql)")
_ = try transaction.execute(sql: sql, parameters: [])
}
}
print("[FTS] Configuration completed successfully.")
} catch {
print("[FTS] Error during FTS setup SQL execution: \(error.localizedDescription)")
throw error
}
} else {
print("[FTS] No FTS SQL statements were generated. Check table names and schema definition.")
}
}
36 changes: 36 additions & 0 deletions Demo/PowerSyncExample/PowerSync/SearchResultItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// SearchResultItem.swift
// PowerSyncExample
//
// Created by Wade Morris on 4/9/25.
//

import Foundation

enum SearchResultType {
case list
case todo
}

struct SearchResultItem: Identifiable, Hashable {
let id: String
let type: SearchResultType
let content: AnyHashable

var listContent: ListContent? {
content as? ListContent
}

var todo: Todo? {
content as? Todo
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(type)
}

static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
lhs.id == rhs.id && lhs.type == rhs.type
}
}
Loading