|
| 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