Skip to content

Commit e970fd4

Browse files
Merge remote-tracking branch 'origin/main' into grdb
2 parents 7aae6cf + 08fd253 commit e970fd4

23 files changed

+834
-87
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.6.0
4+
5+
* Update core extension to 0.4.6 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.6))
6+
* Add `getCrudTransactions()`, returning an async sequence of transactions.
7+
* Compatibility with Swift 6.2 and XCode 26.
8+
* Update minimum MacOS target to v12
9+
* Update minimum iOS target to v15
10+
* [Attachment Helpers] Added automatic verification or records' `local_uri` values on `AttachmentQueue` initialization.
11+
initialization can be awaited with `AttachmentQueue.waitForInit()`. `AttachmentQueue.startSync()` also performs this verification.
12+
`waitForInit()` is only recommended if `startSync` is not called directly after creating the queue.
13+
314
## 1.5.1
415

516
* Update core extension to 0.4.5 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.5))

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 */,

Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import SwiftUI
2+
3+
struct SearchResultRow: View {
4+
let item: SearchResultItem
5+
6+
var body: some View {
7+
HStack {
8+
9+
Image(
10+
systemName: {
11+
switch item.content {
12+
case .list:
13+
return "list.bullet"
14+
case .todo:
15+
return "checkmark.circle"
16+
}
17+
}()
18+
)
19+
.foregroundColor(.secondary)
20+
21+
switch item.content {
22+
case .list(let listContent):
23+
Text(listContent.name)
24+
25+
case .todo(let todo):
26+
Text(todo.description)
27+
.strikethrough(todo.isComplete, color: .secondary)
28+
.foregroundColor(todo.isComplete ? .secondary : .primary)
29+
}
30+
31+
Spacer()
32+
33+
Image(systemName: "chevron.right")
34+
.font(.caption.weight(.bold))
35+
.foregroundColor(.secondary.opacity(0.5))
36+
}
37+
.contentShape(Rectangle())
38+
}
39+
}
40+
41+
#Preview {
42+
List {
43+
SearchResultRow(
44+
item: SearchResultItem(
45+
id: UUID().uuidString,
46+
content: .list(
47+
ListContent(
48+
id: UUID().uuidString,
49+
name: "Groceries",
50+
createdAt: "now",
51+
ownerId: "user1"
52+
)
53+
)
54+
)
55+
)
56+
SearchResultRow(
57+
item: SearchResultItem(
58+
id: UUID().uuidString,
59+
content: .todo(
60+
Todo(
61+
id: UUID().uuidString,
62+
listId: "list1",
63+
description: "Buy milk",
64+
isComplete: false
65+
)
66+
)
67+
)
68+
)
69+
SearchResultRow(
70+
item: SearchResultItem(
71+
id: UUID().uuidString,
72+
content: .todo(
73+
Todo(
74+
id: UUID().uuidString,
75+
listId: "list1",
76+
description: "Walk the dog",
77+
isComplete: true
78+
)
79+
)
80+
)
81+
)
82+
}
83+
}

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

0 commit comments

Comments
 (0)