Skip to content

Commit d2f1926

Browse files
authored
perf: reduce memory and CPU usage in result storage, cell rendering, and query fetching (#432)
* perf: reduce memory and CPU usage in result storage, cell rendering, and query fetching * docs: simplify changelog entry * fix: address code review — remove BLOB exclusion, narrow TEXT exclusion, wire display cache invalidation * fix: address CodeRabbit review — safe dict init, column bounds check, schema-backed cache, db-scoped cache key, negative index guard
1 parent 417a316 commit d2f1926

40 files changed

+905
-333
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Reduce memory and CPU usage: flatten row storage, cache cell display values, lazy-load BLOB/TEXT columns
13+
1014
## [0.23.0] - 2026-03-22
1115

1216
### Added

TablePro/Core/Plugins/QueryResultExportDataSource.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
2727
// Snapshot data at init time for thread safety
2828
self.columns = rowBuffer.columns
2929
self.columnTypeNames = rowBuffer.columnTypes.map { $0.rawType ?? "" }
30-
self.rows = rowBuffer.rows.map { $0.values }
30+
self.rows = rowBuffer.rows
3131
}
3232

3333
func fetchRows(table: String, databaseName: String, offset: Int, limit: Int) async throws -> PluginQueryResult {

TablePro/Core/Services/ColumnType.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ enum ColumnType: Equatable {
9696
return false
9797
}
9898

99+
/// Whether this type is a very large text type that should be excluded from browse queries.
100+
/// Only MEDIUMTEXT (16MB), LONGTEXT (4GB), and CLOB — not plain TEXT (65KB) or TINYTEXT (255B).
101+
var isVeryLongText: Bool {
102+
guard let raw = rawType?.uppercased() else { return false }
103+
return raw == "MEDIUMTEXT" || raw == "LONGTEXT" || raw == "CLOB"
104+
}
105+
99106
/// Whether this type is an enum column
100107
var isEnumType: Bool {
101108
switch self {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// CellDisplayFormatter.swift
3+
// TablePro
4+
//
5+
// Pure formatter that transforms raw cell values into display-ready strings.
6+
// Used by InMemoryRowProvider's display cache to compute values once per cell.
7+
//
8+
9+
import Foundation
10+
11+
@MainActor
12+
enum CellDisplayFormatter {
13+
static let maxDisplayLength = 10_000
14+
15+
static func format(_ rawValue: String?, columnType: ColumnType?) -> String? {
16+
guard let value = rawValue, !value.isEmpty else { return rawValue }
17+
18+
var displayValue = value
19+
20+
if let columnType {
21+
if columnType.isDateType {
22+
if let formatted = DateFormattingService.shared.format(dateString: displayValue) {
23+
displayValue = formatted
24+
}
25+
} else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) {
26+
displayValue = BlobFormattingService.shared.formatIfNeeded(
27+
displayValue, columnType: columnType, for: .grid
28+
)
29+
}
30+
}
31+
32+
let nsDisplay = displayValue as NSString
33+
if nsDisplay.length > maxDisplayLength {
34+
displayValue = nsDisplay.substring(to: maxDisplayLength) + "..."
35+
}
36+
37+
displayValue = displayValue.sanitizedForCellDisplay
38+
39+
return displayValue
40+
}
41+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// ColumnExclusionPolicy.swift
3+
// TablePro
4+
//
5+
// Determines which columns should be excluded from table browse queries
6+
// to avoid fetching large BLOB/TEXT data unnecessarily.
7+
//
8+
9+
import Foundation
10+
11+
/// Describes a column excluded from SELECT with a placeholder expression
12+
struct ColumnExclusion {
13+
let columnName: String
14+
let placeholderExpression: String
15+
}
16+
17+
/// Determines which columns to exclude from table browse queries
18+
enum ColumnExclusionPolicy {
19+
static func exclusions(
20+
columns: [String],
21+
columnTypes: [ColumnType],
22+
databaseType: DatabaseType,
23+
quoteIdentifier: (String) -> String
24+
) -> [ColumnExclusion] {
25+
// NoSQL databases use custom query builders, not SQL SELECT
26+
if databaseType == .mongodb || databaseType == .redis { return [] }
27+
28+
var result: [ColumnExclusion] = []
29+
let count = min(columns.count, columnTypes.count)
30+
31+
for i in 0..<count {
32+
let col = columns[i]
33+
let colType = columnTypes[i]
34+
let quoted = quoteIdentifier(col)
35+
36+
// Only exclude very large text types (MEDIUMTEXT, LONGTEXT, CLOB).
37+
// Plain TEXT/TINYTEXT are small enough to fetch in full.
38+
// BLOB columns are NOT excluded because no lazy-load fetch path exists
39+
// for editing, export, or change tracking — placeholder values would corrupt data.
40+
if colType.isVeryLongText {
41+
let substringExpr = substringExpression(for: databaseType, column: quoted, length: 256)
42+
result.append(ColumnExclusion(columnName: col, placeholderExpression: substringExpr))
43+
}
44+
}
45+
46+
return result
47+
}
48+
49+
private static func substringExpression(for dbType: DatabaseType, column: String, length: Int) -> String {
50+
switch dbType {
51+
case .sqlite:
52+
return "SUBSTR(\(column), 1, \(length))"
53+
default:
54+
return "SUBSTRING(\(column), 1, \(length))"
55+
}
56+
}
57+
}

TablePro/Core/Services/Query/RowOperationsManager.swift

Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,20 @@ final class RowOperationsManager {
3939
func addNewRow(
4040
columns: [String],
4141
columnDefaults: [String: String?],
42-
resultRows: inout [QueryResultRow]
42+
resultRows: inout [[String?]]
4343
) -> (rowIndex: Int, values: [String?])? {
44-
// Create new row values with DEFAULT markers
4544
var newRowValues: [String?] = []
4645
for column in columns {
4746
if let defaultValue = columnDefaults[column], defaultValue != nil {
48-
// Use __DEFAULT__ marker so generateInsertSQL skips this column
4947
newRowValues.append("__DEFAULT__")
5048
} else {
51-
// NULL for columns without defaults
5249
newRowValues.append(nil)
5350
}
5451
}
5552

56-
// Add to resultRows
5753
let newRowIndex = resultRows.count
58-
let newRow = QueryResultRow(id: newRowIndex, values: newRowValues)
59-
resultRows.append(newRow)
54+
resultRows.append(newRowValues)
6055

61-
// Record in change manager as pending INSERT
6256
changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues)
6357

6458
return (newRowIndex, newRowValues)
@@ -75,26 +69,20 @@ final class RowOperationsManager {
7569
func duplicateRow(
7670
sourceRowIndex: Int,
7771
columns: [String],
78-
resultRows: inout [QueryResultRow]
72+
resultRows: inout [[String?]]
7973
) -> (rowIndex: Int, values: [String?])? {
8074
guard sourceRowIndex < resultRows.count else { return nil }
8175

82-
// Copy values from selected row
83-
let sourceRow = resultRows[sourceRowIndex]
84-
var newValues = sourceRow.values
76+
var newValues = resultRows[sourceRowIndex]
8577

86-
// Set primary key column to DEFAULT so DB auto-generates
8778
if let pkColumn = changeManager.primaryKeyColumn,
8879
let pkIndex = columns.firstIndex(of: pkColumn) {
8980
newValues[pkIndex] = "__DEFAULT__"
9081
}
9182

92-
// Add the duplicated row
9383
let newRowIndex = resultRows.count
94-
let newRow = QueryResultRow(id: newRowIndex, values: newValues)
95-
resultRows.append(newRow)
84+
resultRows.append(newValues)
9685

97-
// Record in change manager as pending INSERT
9886
changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues)
9987

10088
return (newRowIndex, newValues)
@@ -109,26 +97,22 @@ final class RowOperationsManager {
10997
/// - Returns: Next row index to select after deletion, or -1 if no rows left
11098
func deleteSelectedRows(
11199
selectedIndices: Set<Int>,
112-
resultRows: inout [QueryResultRow]
100+
resultRows: inout [[String?]]
113101
) -> Int {
114102
guard !selectedIndices.isEmpty else { return -1 }
115103

116-
// Separate inserted rows from existing rows
117104
var insertedRowsToDelete: [Int] = []
118105
var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = []
119106

120-
// Find the lowest selected row index for selection movement
121107
let minSelectedRow = selectedIndices.min() ?? 0
122108
let maxSelectedRow = selectedIndices.max() ?? 0
123109

124-
// Categorize rows (process in descending order to maintain correct indices)
125110
for rowIndex in selectedIndices.sorted(by: >) {
126111
if changeManager.isRowInserted(rowIndex) {
127112
insertedRowsToDelete.append(rowIndex)
128113
} else if !changeManager.isRowDeleted(rowIndex) {
129114
if rowIndex < resultRows.count {
130-
let originalRow = resultRows[rowIndex].values
131-
existingRowsToDelete.append((rowIndex: rowIndex, originalRow: originalRow))
115+
existingRowsToDelete.append((rowIndex: rowIndex, originalRow: resultRows[rowIndex]))
132116
}
133117
}
134118
}
@@ -174,15 +158,15 @@ final class RowOperationsManager {
174158
/// Undo the last change
175159
/// - Parameter resultRows: Current rows (will be mutated)
176160
/// - Returns: Updated selection indices
177-
func undoLastChange(resultRows: inout [QueryResultRow]) -> Set<Int>? {
161+
func undoLastChange(resultRows: inout [[String?]]) -> Set<Int>? {
178162
guard let result = changeManager.undoLastChange() else { return nil }
179163

180164
var adjustedSelection: Set<Int>?
181165

182166
switch result.action {
183167
case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _):
184168
if rowIndex < resultRows.count {
185-
resultRows[rowIndex].values[columnIndex] = previousValue
169+
resultRows[rowIndex][columnIndex] = previousValue
186170
}
187171

188172
case .rowInsertion(let rowIndex):
@@ -192,22 +176,16 @@ final class RowOperationsManager {
192176
}
193177

194178
case .rowDeletion:
195-
// Row is restored in changeManager - visual indicator will be removed
196179
break
197180

198181
case .batchRowDeletion:
199-
// All rows are restored in changeManager
200182
break
201183

202184
case .batchRowInsertion(let rowIndices, let rowValues):
203-
// Restore deleted inserted rows - add them back to resultRows
204185
for (index, rowIndex) in rowIndices.enumerated().reversed() {
205186
guard index < rowValues.count else { continue }
206187
guard rowIndex <= resultRows.count else { continue }
207-
208-
let values = rowValues[index]
209-
let newRow = QueryResultRow(id: rowIndex, values: values)
210-
resultRows.insert(newRow, at: rowIndex)
188+
resultRows.insert(rowValues[index], at: rowIndex)
211189
}
212190
}
213191

@@ -219,32 +197,28 @@ final class RowOperationsManager {
219197
/// - resultRows: Current rows (will be mutated)
220198
/// - columns: Column names for new row creation
221199
/// - Returns: Updated selection indices
222-
func redoLastChange(resultRows: inout [QueryResultRow], columns: [String]) -> Set<Int>? {
200+
func redoLastChange(resultRows: inout [[String?]], columns: [String]) -> Set<Int>? {
223201
guard let result = changeManager.redoLastChange() else { return nil }
224202

225203
switch result.action {
226204
case .cellEdit(let rowIndex, let columnIndex, _, _, let newValue):
227205
if rowIndex < resultRows.count {
228-
resultRows[rowIndex].values[columnIndex] = newValue
206+
resultRows[rowIndex][columnIndex] = newValue
229207
}
230208

231209
case .rowInsertion(let rowIndex):
232210
let newValues = [String?](repeating: nil, count: columns.count)
233-
let newRow = QueryResultRow(id: rowIndex, values: newValues)
234211
if rowIndex <= resultRows.count {
235-
resultRows.insert(newRow, at: rowIndex)
212+
resultRows.insert(newValues, at: rowIndex)
236213
}
237214

238215
case .rowDeletion:
239-
// Row is re-marked as deleted in changeManager
240216
break
241217

242218
case .batchRowDeletion:
243-
// Rows are re-marked as deleted
244219
break
245220

246221
case .batchRowInsertion(let rowIndices, _):
247-
// Redo the deletion - remove the rows from resultRows again
248222
for rowIndex in rowIndices.sorted(by: >) {
249223
guard rowIndex < resultRows.count else { continue }
250224
resultRows.remove(at: rowIndex)
@@ -264,7 +238,7 @@ final class RowOperationsManager {
264238
/// - Returns: Adjusted selection indices
265239
func undoInsertRow(
266240
at rowIndex: Int,
267-
resultRows: inout [QueryResultRow],
241+
resultRows: inout [[String?]],
268242
selectedIndices: Set<Int>
269243
) -> Set<Int> {
270244
guard rowIndex >= 0 && rowIndex < resultRows.count else { return selectedIndices }
@@ -297,7 +271,7 @@ final class RowOperationsManager {
297271
/// - includeHeaders: Whether to prepend column headers as the first TSV line
298272
func copySelectedRowsToClipboard(
299273
selectedIndices: Set<Int>,
300-
resultRows: [QueryResultRow],
274+
resultRows: [[String?]],
301275
columns: [String] = [],
302276
includeHeaders: Bool = false
303277
) {
@@ -315,7 +289,7 @@ final class RowOperationsManager {
315289

316290
let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices
317291

318-
let columnCount = resultRows.first?.values.count ?? 1
292+
let columnCount = resultRows.first?.count ?? 1
319293
let estimatedRowLength = columnCount * 12
320294
var result = ""
321295
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)
@@ -329,9 +303,8 @@ final class RowOperationsManager {
329303

330304
for rowIndex in indicesToCopy {
331305
guard rowIndex < resultRows.count else { continue }
332-
let row = resultRows[rowIndex]
333306
if !result.isEmpty { result.append("\n") }
334-
for (colIdx, value) in row.values.enumerated() {
307+
for (colIdx, value) in resultRows[rowIndex].enumerated() {
335308
if colIdx > 0 { result.append("\t") }
336309
result.append(value ?? "NULL")
337310
}
@@ -358,7 +331,7 @@ final class RowOperationsManager {
358331
func pasteRowsFromClipboard(
359332
columns: [String],
360333
primaryKeyColumn: String?,
361-
resultRows: inout [QueryResultRow],
334+
resultRows: inout [[String?]],
362335
clipboard: ClipboardProvider? = nil,
363336
parser: RowDataParser? = nil
364337
) -> [(rowIndex: Int, values: [String?])] {
@@ -448,18 +421,16 @@ final class RowOperationsManager {
448421
/// - Returns: Array of (rowIndex, values) for inserted rows
449422
private func insertParsedRows(
450423
_ parsedRows: [ParsedRow],
451-
into resultRows: inout [QueryResultRow]
424+
into resultRows: inout [[String?]]
452425
) -> [(rowIndex: Int, values: [String?])] {
453426
var pastedRowInfo: [(Int, [String?])] = []
454427

455428
for parsedRow in parsedRows {
456429
let rowValues = parsedRow.values
457430

458-
// Add to resultRows
459-
resultRows.append(QueryResultRow(id: resultRows.count, values: rowValues))
431+
resultRows.append(rowValues)
460432
let newRowIndex = resultRows.count - 1
461433

462-
// Record as pending INSERT in change manager
463434
changeManager.recordRowInsertion(rowIndex: newRowIndex, values: rowValues)
464435

465436
pastedRowInfo.append((newRowIndex, rowValues))

0 commit comments

Comments
 (0)