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 1 commit
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
Prev Previous commit
Next Next commit
refactor: clean up code and improve FTS setup logic
- Removed unnecessary whitespace in `RootView.swift` and `SearchScreen.swift`.
- Enhanced FTS setup in `FtsSetup.swift` to check for existing tables before configuration.
- Updated search functionality in `SearchScreen.swift` for better readability and structure.
  • Loading branch information
morristech committed Apr 9, 2025
commit 8879626ba3e001bf82cf4879f67e8c6bbbde4a0d
22 changes: 15 additions & 7 deletions Demo/PowerSyncExample/PowerSync/FtsSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import Foundation
import PowerSync

/// Defines the type of JSON extract operation needed for generating SQL.
enum ExtractType {
case columnOnly
case columnInOperation
Expand Down Expand Up @@ -126,12 +125,24 @@ func getFtsSetupSqlStatements(
/// - 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
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] = []

// --- Define FTS configurations for each table ---

// Configure FTS for the 'lists' table
if let listStatements = getFtsSetupSqlStatements(
tableName: LISTS_TABLE,
columns: ["name"],
Expand All @@ -142,11 +153,9 @@ func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws {
allSqlStatements.append(contentsOf: listStatements)
}

// Configure FTS for the 'todos' table
if let todoStatements = getFtsSetupSqlStatements(
tableName: TODOS_TABLE,
columns: ["description"],
// columns: ["description", "list_id"], // If you need to search by list_id via FTS
schema: schema
) {
print("[FTS] Generated \(todoStatements.count) SQL statements for '\(TODOS_TABLE)' table.")
Expand All @@ -158,7 +167,6 @@ func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws {
if !allSqlStatements.isEmpty {
do {
print("[FTS] Executing \(allSqlStatements.count) SQL statements in a transaction...")
// Execute all setup statements within a single database transaction
_ = try await db.writeTransaction { transaction in
for sql in allSqlStatements {
print("[FTS] Executing SQL:\n\(sql)")
Expand Down
1 change: 0 additions & 1 deletion Demo/PowerSyncExample/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import SwiftUI

struct RootView: View {
@Environment(SystemManager.self) var system

@State private var authModel = AuthModel()
@State private var navigationModel = NavigationModel()

Expand Down
56 changes: 29 additions & 27 deletions Demo/PowerSyncExample/Screens/SearchScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ struct SearchScreen: View {
@State private var isLoading: Bool = false
@State private var searchError: String? = nil
@State private var searchTask: Task<Void, Never>? = nil

var body: some View {

NavigationView {
List {
if isLoading {
Expand All @@ -28,56 +27,57 @@ struct SearchScreen: View {
} else if let error = searchError {
Text("Error: \(error)")
} else if searchText.isEmpty {
ContentUnavailableView("Search Lists & Todos", systemImage: "magnifyingglass")
ContentUnavailableView("Search Lists & Todos", systemImage: "magnifyingglass")
} else if searchResults.isEmpty && !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
ContentUnavailableView.search(text: searchText)
} else {
ForEach(searchResults) { item in
SearchResultRow(item: item)
}
}
}
.navigationTitle("Search")
.searchable(text: $searchText, prompt: "Search Lists & Todos")
.searchable(text: $searchText,
placement: .toolbar,
prompt: "Search Lists & Todos")
.onChange(of: searchText) { _, newValue in
triggerSearch(term: newValue)
triggerSearch(term: newValue)
}
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty && !isLoading {
searchResults = []
searchError = nil
}
}
}
.navigationViewStyle(.stack)
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty && !isLoading {
searchResults = []
searchError = nil
}
}
}.navigationViewStyle(.stack)
}

private func triggerSearch(term: String) {
searchTask?.cancel()

let trimmedTerm = term.trimmingCharacters(in: .whitespacesAndNewlines)

guard !trimmedTerm.isEmpty else {
self.searchResults = []
self.searchError = nil
self.isLoading = false
return
}

self.isLoading = false
self.searchError = nil

searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(300))

self.isLoading = true

print("Performing search for: \(trimmedTerm)")
let results = try await system.searchListsAndTodos(searchTerm: trimmedTerm)

try Task.checkCancellation()

self.searchResults = results.compactMap { item in
if let list = item as? ListContent {
return SearchResultItem(id: list.id, type: .list, content: list)
Expand All @@ -88,21 +88,23 @@ struct SearchScreen: View {
}
self.searchError = nil
print("Search completed with \(self.searchResults.count) results.")

} catch is CancellationError {
print("Search task cancelled.")
} catch {
print("Search failed: \(error.localizedDescription)")
self.searchError = error.localizedDescription
self.searchResults = []
}

self.isLoading = false
}
}
}

#Preview {
SearchScreen()
.environment(SystemManager())
NavigationStack {
SearchScreen()
.environment(SystemManager())
}
}