From ae9e7cd802ccc0a18fa4aefc3b619896cb30dd82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 18 Mar 2026 22:56:42 +0700 Subject: [PATCH 1/3] perf: fix N+1 fetchAllForeignKeys with bulk queries (HIGH-6) - fetchForeignKeys(forTables:) default now calls fetchAllForeignKeys() once and filters, leveraging existing bulk SQL in MySQL/PostgreSQL/MSSQL - Add bulk fetchAllForeignKeys for SQLite using pragma_foreign_key_list joined with sqlite_master (single query instead of per-table pragma) - Add performance guidance docs to PluginDatabaseDriver default impls --- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 43 ++++++++++++++++--- .../PluginDatabaseDriver.swift | 4 ++ TablePro/Core/Database/DatabaseDriver.swift | 14 ++---- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 67a31892e..8b51ab557 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -551,13 +551,44 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { - let tables = try await fetchTables(schema: schema) - var result: [String: [PluginForeignKeyInfo]] = [:] - for table in tables { - let fks = try await fetchForeignKeys(table: table.name, schema: schema) - if !fks.isEmpty { result[table.name] = fks } + let query = """ + SELECT m.name AS table_name, p.id, p."table" AS referenced_table, + p."from" AS column_name, p."to" AS referenced_column, + p.on_update, p.on_delete + FROM sqlite_master m, pragma_foreign_key_list(m.name) p + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, p.id, p.seq + """ + let result = try await execute(query: query) + + var allForeignKeys: [String: [PluginForeignKeyInfo]] = [:] + + for row in result.rows { + guard row.count >= 7, + let tableName = row[0], + let id = row[1], + let refTable = row[2], + let fromCol = row[3], + let toCol = row[4] else { + continue + } + + let onUpdate = row[5] ?? "NO ACTION" + let onDelete = row[6] ?? "NO ACTION" + + let fk = PluginForeignKeyInfo( + name: "fk_\(tableName)_\(id)", + column: fromCol, + referencedTable: refTable, + referencedColumn: toCol, + onDelete: onDelete, + onUpdate: onUpdate + ) + + allForeignKeys[tableName, default: []].append(fk) } - return result + + return allForeignKeys } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 10f62e5f1..d33c48c1a 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -165,6 +165,8 @@ public extension PluginDatabaseDriver { func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { nil } + /// Default: fetches columns per-table sequentially (N+1 round-trips). + /// SQL drivers should override with a single bulk query (e.g. INFORMATION_SCHEMA.COLUMNS). func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let tables = try await fetchTables(schema: schema) var result: [String: [PluginColumnInfo]] = [:] @@ -174,6 +176,8 @@ public extension PluginDatabaseDriver { return result } + /// Default: fetches foreign keys per-table sequentially (N+1 round-trips). + /// SQL drivers should override with a single bulk query (e.g. INFORMATION_SCHEMA.KEY_COLUMN_USAGE). func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let tables = try await fetchTables(schema: schema) var result: [String: [PluginForeignKeyInfo]] = [:] diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index a86c0ec71..205415cc7 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -231,17 +231,9 @@ extension DatabaseDriver { } func fetchForeignKeys(forTables tableNames: [String]) async throws -> [String: [ForeignKeyInfo]] { - var result: [String: [ForeignKeyInfo]] = [:] - for name in tableNames { - do { - let fks = try await fetchForeignKeys(table: name) - if !fks.isEmpty { result[name] = fks } - } catch { - Logger(subsystem: "com.TablePro", category: "DatabaseDriver") - .debug("Failed to fetch foreign keys for \(name): \(error.localizedDescription)") - } - } - return result + let all = try await fetchAllForeignKeys() + let nameSet = Set(tableNames) + return all.filter { nameSet.contains($0.key) } } /// Default fetchAllColumns: falls back to per-table fetchColumns (N+1). From 8ebd8a2a3350026c89bd9ce3e874099dc479f32a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 08:23:24 +0700 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback - Replace force-unwrap UnicodeScalar(char)! with guard-let for surrogate safety - Add threshold in fetchForeignKeys(forTables:) to avoid full schema scan for small subsets --- .../PluginDatabaseDriver.swift | 40 +++++++++++++++---- TablePro/Core/Database/DatabaseDriver.swift | 9 +++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d33c48c1a..74200bc71 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -292,14 +292,22 @@ public extension PluginDatabaseDriver { if isEscaped { isEscaped = false - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } i += 1 continue } if char == backslash && (inSingleQuote || inDoubleQuote) { isEscaped = true - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } i += 1 continue } @@ -318,7 +326,11 @@ public extension PluginDatabaseDriver { } paramIndex += 1 } else { - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } } i += 1 @@ -341,7 +353,11 @@ public extension PluginDatabaseDriver { if isEscaped { isEscaped = false - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } i += 1 continue } @@ -349,7 +365,11 @@ public extension PluginDatabaseDriver { let backslash: UInt16 = 0x5C // \\ if char == backslash && (inSingleQuote || inDoubleQuote) { isEscaped = true - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } i += 1 continue } @@ -369,7 +389,9 @@ public extension PluginDatabaseDriver { while j < length { let digitChar = nsQuery.character(at: j) if digitChar >= 0x30 && digitChar <= 0x39 { // 0-9 - numStr.append(Character(UnicodeScalar(digitChar)!)) + if let scalar = UnicodeScalar(digitChar) { + numStr.append(Character(scalar)) + } j += 1 } else { break @@ -386,7 +408,11 @@ public extension PluginDatabaseDriver { } } - sql.append(Character(UnicodeScalar(char)!)) + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } i += 1 } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 205415cc7..2fce4c693 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -231,6 +231,15 @@ extension DatabaseDriver { } func fetchForeignKeys(forTables tableNames: [String]) async throws -> [String: [ForeignKeyInfo]] { + // For small subsets, per-table fetch avoids scanning the entire schema + if tableNames.count <= 5 { + var result: [String: [ForeignKeyInfo]] = [:] + for tableName in tableNames { + let fks = try await fetchForeignKeys(table: tableName) + if !fks.isEmpty { result[tableName] = fks } + } + return result + } let all = try await fetchAllForeignKeys() let nameSet = Set(tableNames) return all.filter { nameSet.contains($0.key) } From 7fb3b09cfeb2195b62038cac8db066e8c03cdb8c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 08:31:08 +0700 Subject: [PATCH 3/3] fix: consistent error handling in fetchForeignKeys(forTables:) paths --- TablePro/Core/Database/DatabaseDriver.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 2fce4c693..657ae91b0 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -235,8 +235,13 @@ extension DatabaseDriver { if tableNames.count <= 5 { var result: [String: [ForeignKeyInfo]] = [:] for tableName in tableNames { - let fks = try await fetchForeignKeys(table: tableName) - if !fks.isEmpty { result[tableName] = fks } + do { + let fks = try await fetchForeignKeys(table: tableName) + if !fks.isEmpty { result[tableName] = fks } + } catch { + Logger(subsystem: "com.TablePro", category: "DatabaseDriver") + .debug("Failed to fetch foreign keys for \(tableName): \(error.localizedDescription)") + } } return result }