Skip to content

Commit 2dfda29

Browse files
initial implementation
1 parent 67453bb commit 2dfda29

File tree

9 files changed

+529
-21
lines changed

9 files changed

+529
-21
lines changed

Demo/PowerSyncExample.xcodeproj/project.pbxproj

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 60;
6+
objectVersion = 56;
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
0B29DBE92E686D6000D60A06 /* FtsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */; };
11+
0B29DBEB2E68876500D60A06 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */; };
12+
0B29DBED2E68887A00D60A06 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEC2E68887700D60A06 /* SearchScreen.swift */; };
13+
0B29DBEF2E68898C00D60A06 /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */; };
1014
6A4AD3852B9EE763005CBFD4 /* SupabaseConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */; };
1115
6A4AD3892B9EEB21005CBFD4 /* _Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */; };
1216
6A4AD3902B9EF775005CBFD4 /* ErrorText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD38F2B9EF775005CBFD4 /* ErrorText.swift */; };
@@ -60,6 +64,10 @@
6064
/* End PBXCopyFilesBuildPhase section */
6165

6266
/* Begin PBXFileReference section */
67+
0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FtsSetup.swift; sourceTree = "<group>"; };
68+
0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
69+
0B29DBEC2E68887700D60A06 /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
70+
0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = "<group>"; };
6371
18CC627A2CC7A8B5009F7CDE /* powersync-kotlin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "powersync-kotlin"; path = "../powersync-kotlin"; sourceTree = SOURCE_ROOT; };
6472
6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseConnector.swift; sourceTree = "<group>"; };
6573
6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _Secrets.swift; sourceTree = "<group>"; };
@@ -200,6 +208,7 @@
200208
B65C4D6B2C60D36700176007 /* Screens */ = {
201209
isa = PBXGroup;
202210
children = (
211+
0B29DBEC2E68887700D60A06 /* SearchScreen.swift */,
203212
6A9669032B9EE6FA00B05DCF /* SignInScreen.swift */,
204213
B65C4D6C2C60D38B00176007 /* HomeScreen.swift */,
205214
B65C4D702C60D7D800176007 /* SignUpScreen.swift */,
@@ -211,6 +220,7 @@
211220
B65C4D6E2C60D52E00176007 /* Components */ = {
212221
isa = PBXGroup;
213222
children = (
223+
0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */,
214224
6ABD78792B9F2D8300558A41 /* TodoListRow.swift */,
215225
6ABD786A2B9F2C1500558A41 /* TodoListView.swift */,
216226
B66658622C621CA700159A81 /* AddTodoListView.swift */,
@@ -225,6 +235,8 @@
225235
B65C4D6F2C60D58500176007 /* PowerSync */ = {
226236
isa = PBXGroup;
227237
children = (
238+
0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */,
239+
0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */,
228240
BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */,
229241
6A7315BA2B98BDD30004CB17 /* SystemManager.swift */,
230242
6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */,
@@ -556,6 +568,8 @@
556568
6ABD787C2B9F2E6700558A41 /* Debug.swift in Sources */,
557569
B666585B2C620C3900159A81 /* Constants.swift in Sources */,
558570
6ABD78802B9F2F1300558A41 /* AddListView.swift in Sources */,
571+
0B29DBEF2E68898C00D60A06 /* SearchResultRow.swift in Sources */,
572+
0B29DBED2E68887A00D60A06 /* SearchScreen.swift in Sources */,
559573
6A4AD3892B9EEB21005CBFD4 /* _Secrets.swift in Sources */,
560574
B65C4D712C60D7D800176007 /* SignUpScreen.swift in Sources */,
561575
B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */,
@@ -570,11 +584,13 @@
570584
B66658612C62179E00159A81 /* ListView.swift in Sources */,
571585
6ABD78782B9F2D2800558A41 /* Schema.swift in Sources */,
572586
BEE4708B2E3BBB2500140D11 /* Secrets.swift in Sources */,
587+
0B29DBE92E686D6000D60A06 /* FtsSetup.swift in Sources */,
573588
B65C4D6D2C60D38B00176007 /* HomeScreen.swift in Sources */,
574589
6A7315882B9854220004CB17 /* PowerSyncExampleApp.swift in Sources */,
575590
B666585F2C62115300159A81 /* ListRow.swift in Sources */,
576591
BE2F26EC2DA54B2F0080F1AE /* SupabaseRemoteStorage.swift in Sources */,
577592
B66658632C621CA700159A81 /* AddTodoListView.swift in Sources */,
593+
0B29DBEB2E68876500D60A06 /* SearchResultItem.swift in Sources */,
578594
B666585D2C620E9E00159A81 /* WifiIcon.swift in Sources */,
579595
6A9669042B9EE6FA00B05DCF /* SignInScreen.swift in Sources */,
580596
6A7315BB2B98BDD30004CB17 /* SystemManager.swift in Sources */,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// SearchResultRow.swift
3+
// PowerSyncExample
4+
//
5+
// Created by Joshua Brink on 2025/09/03.
6+
//
7+
8+
import SwiftUI
9+
10+
struct SearchResultRow: View {
11+
let item: SearchResultItem
12+
13+
var body: some View {
14+
HStack {
15+
16+
Image(systemName: item.type == .list ? "list.bullet" : "checkmark.circle")
17+
.foregroundColor(.secondary)
18+
19+
if let list = item.listContent {
20+
Text(list.name)
21+
} else if let todo = item.todo {
22+
Text(todo.description)
23+
.strikethrough(todo.isComplete, color: .secondary)
24+
.foregroundColor(todo.isComplete ? .secondary : .primary)
25+
} else {
26+
Text("Unknown item")
27+
}
28+
29+
Spacer()
30+
31+
Image(systemName: "chevron.right")
32+
.font(.caption.weight(.bold))
33+
.foregroundColor(.secondary.opacity(0.5))
34+
}
35+
.contentShape(Rectangle())
36+
}
37+
}
38+
39+
#Preview {
40+
List {
41+
SearchResultRow(item: SearchResultItem(
42+
id: UUID().uuidString,
43+
type: .list,
44+
content: ListContent(id: UUID().uuidString, name: "Groceries", createdAt: "now", ownerId: "user1")
45+
))
46+
SearchResultRow(item: SearchResultItem(
47+
id: UUID().uuidString,
48+
type: .todo,
49+
content: Todo(id: UUID().uuidString, listId: "list1", description: "Buy milk", isComplete: false)
50+
))
51+
SearchResultRow(item: SearchResultItem(
52+
id: UUID().uuidString,
53+
type: .todo,
54+
content: Todo(id: UUID().uuidString, listId: "list1", description: "Walk the dog", isComplete: true)
55+
))
56+
}
57+
}

Demo/PowerSyncExample/Navigation.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enum Route: Hashable {
44
case home
55
case signIn
66
case signUp
7+
case search
78
}
89

910
@Observable
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//
2+
// FullTextSearch.swift
3+
// PowerSyncExample
4+
//
5+
// Created by Joshua Brink on 2025/09/03.
6+
//
7+
import Foundation
8+
import PowerSync
9+
10+
enum ExtractType {
11+
case columnOnly
12+
case columnInOperation
13+
}
14+
15+
/// Generates SQL JSON extract expressions for FTS triggers.
16+
///
17+
/// - Parameters:
18+
/// - type: The type of extraction needed (`columnOnly` or `columnInOperation`).
19+
/// - sourceColumn: The JSON source column (e.g., `'data'`, `'NEW.data'`).
20+
/// - columns: The list of column names to extract.
21+
/// - Returns: A comma-separated string of SQL expressions.
22+
func generateJsonExtracts(type: ExtractType, sourceColumn: String, columns: [String]) -> String {
23+
func createExtract(jsonSource: String, columnName: String) -> String {
24+
return "json_extract(\(jsonSource), '$.\"\(columnName)\"')"
25+
}
26+
27+
func generateSingleColumnSql(columnName: String) -> String {
28+
switch type {
29+
case .columnOnly:
30+
return createExtract(jsonSource: sourceColumn, columnName: columnName)
31+
case .columnInOperation:
32+
return "\"\(columnName)\" = \(createExtract(jsonSource: sourceColumn, columnName: columnName))"
33+
}
34+
}
35+
36+
return columns.map(generateSingleColumnSql).joined(separator: ", ")
37+
}
38+
39+
/// Generates the SQL statements required to set up an FTS5 virtual table
40+
/// and corresponding triggers for a given PowerSync table.
41+
///
42+
///
43+
/// - Parameters:
44+
/// - tableName: The public name of the table to index (e.g., "lists", "todos").
45+
/// - columns: The list of column names within the table to include in the FTS index.
46+
/// - schema: The PowerSync `Schema` object to find the internal table name.
47+
/// - tokenizationMethod: The FTS5 tokenization method (e.g., "porter unicode61", "unicode61").
48+
/// - Returns: An array of SQL statements to be executed, or `nil` if the table is not found in the schema.
49+
func getFtsSetupSqlStatements(
50+
tableName: String,
51+
columns: [String],
52+
schema: Schema,
53+
tokenizationMethod: String = "unicode61"
54+
) -> [String]? {
55+
56+
guard let table = schema.tables.first(where: { $0.name == tableName }) else {
57+
print("Table '\(tableName)' not found in schema. Skipping FTS setup for this table.")
58+
return nil
59+
}
60+
let internalName = table.localOnly ? "ps_data_local__\(table.name)" : "ps_data__\(table.name)"
61+
62+
let ftsTableName = "fts_\(tableName)"
63+
64+
let stringColumnsForCreate = columns.map { "\"\($0)\"" }.joined(separator: ", ")
65+
66+
let stringColumnsForInsertList = columns.map { "\"\($0)\"" }.joined(separator: ", ")
67+
68+
var sqlStatements: [String] = []
69+
70+
// 1. Create the FTS5 Virtual Table
71+
sqlStatements.append("""
72+
CREATE VIRTUAL TABLE IF NOT EXISTS \(ftsTableName)
73+
USING fts5(id UNINDEXED, \(stringColumnsForCreate), tokenize='\(tokenizationMethod)');
74+
""")
75+
76+
// 2. Copy existing data from the main table to the FTS table
77+
sqlStatements.append("""
78+
INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList))
79+
SELECT rowid, id, \(generateJsonExtracts(type: .columnOnly, sourceColumn: "data", columns: columns))
80+
FROM \(internalName);
81+
""")
82+
83+
// 3. Create INSERT Trigger
84+
sqlStatements.append("""
85+
CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_\(tableName) AFTER INSERT ON \(internalName)
86+
BEGIN
87+
INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList))
88+
VALUES (
89+
NEW.rowid,
90+
NEW.id,
91+
\(generateJsonExtracts(type: .columnOnly, sourceColumn: "NEW.data", columns: columns))
92+
);
93+
END;
94+
""")
95+
96+
// 4. Create UPDATE Trigger
97+
sqlStatements.append("""
98+
CREATE TRIGGER IF NOT EXISTS fts_update_trigger_\(tableName) AFTER UPDATE ON \(internalName)
99+
BEGIN
100+
UPDATE \(ftsTableName)
101+
SET \(generateJsonExtracts(type: .columnInOperation, sourceColumn: "NEW.data", columns: columns))
102+
WHERE rowid = NEW.rowid;
103+
END;
104+
""")
105+
106+
// 5. Create DELETE Trigger
107+
sqlStatements.append("""
108+
CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_\(tableName) AFTER DELETE ON \(internalName)
109+
BEGIN
110+
DELETE FROM \(ftsTableName) WHERE rowid = OLD.rowid;
111+
END;
112+
""")
113+
114+
return sqlStatements
115+
}
116+
117+
118+
/// Configures Full-Text Search (FTS) tables and triggers for specified tables
119+
/// within the PowerSync database. Call this function during database initialization.
120+
///
121+
/// Executes all generated SQL within a single transaction.
122+
///
123+
/// - Parameters:
124+
/// - db: The initialized `PowerSyncDatabaseProtocol` instance.
125+
/// - schema: The `Schema` instance matching the database.
126+
/// - Throws: An error if the database transaction fails.
127+
func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws {
128+
let ftsCheckTable = "fts_\(LISTS_TABLE)"
129+
let checkSql = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"
130+
131+
do {
132+
let existingTable: String? = try await db.getOptional(sql: checkSql, parameters: [ftsCheckTable]) { cursor in
133+
try cursor.getString(name: "name")
134+
}
135+
136+
if existingTable != nil {
137+
print("[FTS] FTS table '\(ftsCheckTable)' already exists. Skipping setup.")
138+
return
139+
}
140+
} catch {
141+
print("[FTS] Failed to check for existing FTS tables: \(error.localizedDescription). Proceeding with setup attempt.")
142+
}
143+
print("[FTS] Starting FTS configuration...")
144+
var allSqlStatements: [String] = []
145+
146+
if let listStatements = getFtsSetupSqlStatements(
147+
tableName: LISTS_TABLE,
148+
columns: ["name"],
149+
schema: schema,
150+
tokenizationMethod: "porter unicode61"
151+
) {
152+
print("[FTS] Generated \(listStatements.count) SQL statements for '\(LISTS_TABLE)' table.")
153+
allSqlStatements.append(contentsOf: listStatements)
154+
}
155+
156+
if let todoStatements = getFtsSetupSqlStatements(
157+
tableName: TODOS_TABLE,
158+
columns: ["description"],
159+
schema: schema
160+
) {
161+
print("[FTS] Generated \(todoStatements.count) SQL statements for '\(TODOS_TABLE)' table.")
162+
allSqlStatements.append(contentsOf: todoStatements)
163+
}
164+
165+
// --- Execute all generated SQL statements ---
166+
167+
if !allSqlStatements.isEmpty {
168+
do {
169+
print("[FTS] Executing \(allSqlStatements.count) SQL statements in a transaction...")
170+
_ = try await db.writeTransaction { transaction in
171+
for sql in allSqlStatements {
172+
print("[FTS] Executing SQL:\n\(sql)")
173+
_ = try transaction.execute(sql: sql, parameters: [])
174+
}
175+
}
176+
print("[FTS] Configuration completed successfully.")
177+
} catch {
178+
print("[FTS] Error during FTS setup SQL execution: \(error.localizedDescription)")
179+
throw error
180+
}
181+
} else {
182+
print("[FTS] No FTS SQL statements were generated. Check table names and schema definition.")
183+
}
184+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// SearchResultItem.swift
3+
// PowerSyncExample
4+
//
5+
// Created by Joshua Brink on 2025/09/03.
6+
//
7+
8+
import Foundation
9+
10+
enum SearchResultType {
11+
case list
12+
case todo
13+
}
14+
15+
struct SearchResultItem: Identifiable, Hashable {
16+
let id: String
17+
let type: SearchResultType
18+
let content: AnyHashable
19+
20+
var listContent: ListContent? {
21+
content as? ListContent
22+
}
23+
24+
var todo: Todo? {
25+
content as? Todo
26+
}
27+
28+
func hash(into hasher: inout Hasher) {
29+
hasher.combine(id)
30+
hasher.combine(type)
31+
}
32+
33+
static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
34+
lhs.id == rhs.id && lhs.type == rhs.type
35+
}
36+
}

0 commit comments

Comments
 (0)