Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Reduce memory and CPU usage: flatten row storage, cache cell display values, lazy-load BLOB/TEXT columns
- Improve performance: faster sorting, lower memory usage, adaptive tab eviction

## [0.23.0] - 2026-03-22

Expand Down
32 changes: 32 additions & 0 deletions TablePro/Core/Utilities/MemoryPressureAdvisor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// MemoryPressureAdvisor.swift
// TablePro
//

import Foundation

/// Advises on tab eviction budget based on system memory.
enum MemoryPressureAdvisor {
/// Returns the number of inactive tabs that should be kept in memory.
/// Scales with total physical memory since macOS manages virtual memory pressure.
static func budgetForInactiveTabs() -> Int {
let totalBytes = ProcessInfo.processInfo.physicalMemory
let gb: UInt64 = 1_073_741_824

if totalBytes >= 32 * gb {
return 8
} else if totalBytes >= 16 * gb {
return 5
} else if totalBytes >= 8 * gb {
return 3
} else {
return 2
}
}

/// Rough estimate of a tab's memory footprint in bytes.
/// Uses 64 bytes per cell as average (16B String struct + ~48B backing store).
static func estimatedFootprint(rowCount: Int, columnCount: Int) -> Int {
rowCount * columnCount * 64
}
}
30 changes: 30 additions & 0 deletions TablePro/Core/Utilities/SQL/RowSortComparator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// RowSortComparator.swift
// TablePro
//
// Type-aware row value comparator for grid sorting.
//

import Foundation

/// Type-aware row value comparator for grid sorting.
/// Uses String.compare with .numeric option and type-specific fast paths for integer/decimal columns.
enum RowSortComparator {
static func compare(_ lhs: String, _ rhs: String, columnType: ColumnType?) -> ComparisonResult {
if let columnType {
switch columnType {
case .integer:
if let l = Int64(lhs), let r = Int64(rhs) {
return l < r ? .orderedAscending : (l > r ? .orderedDescending : .orderedSame)
}
case .decimal:
if let l = Double(lhs), let r = Double(rhs) {
return l < r ? .orderedAscending : (l > r ? .orderedDescending : .orderedSame)
Comment thread
datlechin marked this conversation as resolved.
}
default:
break
}
}
return lhs.compare(rhs, options: [.numeric])
}
}
4 changes: 3 additions & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ struct MainEditorContentView: View {
? (row1[sortCol.columnIndex] ?? "") : ""
let val2 = sortCol.columnIndex < row2.count
? (row2[sortCol.columnIndex] ?? "") : ""
let result = val1.localizedStandardCompare(val2)
let colType = sortCol.columnIndex < tab.columnTypes.count
? tab.columnTypes[sortCol.columnIndex] : nil
let result = RowSortComparator.compare(val1, val2, columnType: colType)
if result == .orderedSame { continue }
return sortCol.direction == .ascending
? result == .orderedAscending
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,23 @@ extension MainContentCoordinator {
&& !$0.pendingChanges.hasChanges
}

// Sort by oldest first, breaking ties by largest estimated footprint first
let sorted = candidates.sorted {
($0.lastExecutedAt ?? .distantFuture) < ($1.lastExecutedAt ?? .distantFuture)
let t0 = $0.lastExecutedAt ?? .distantFuture
let t1 = $1.lastExecutedAt ?? .distantFuture
if t0 != t1 { return t0 < t1 }
let size0 = MemoryPressureAdvisor.estimatedFootprint(
rowCount: $0.rowBuffer.rows.count,
columnCount: $0.rowBuffer.columns.count
)
let size1 = MemoryPressureAdvisor.estimatedFootprint(
rowCount: $1.rowBuffer.rows.count,
columnCount: $1.rowBuffer.columns.count
)
return size0 > size1
}

let maxInactiveLoaded = 2
let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs()
guard sorted.count > maxInactiveLoaded else { return }
let toEvict = sorted.dropLast(maxInactiveLoaded)

Expand Down
33 changes: 18 additions & 15 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,7 @@ final class MainContentCoordinator {
let tabId = tab.id
let resultVersion = tab.resultVersion
let sortColumns = currentSort.columns
let colTypes = tab.columnTypes

if rows.count > 10_000 {
// Large dataset: sort on background thread to avoid UI freeze
Expand All @@ -1136,7 +1137,11 @@ final class MainContentCoordinator {

let sortStartTime = Date()
let task = Task.detached { [weak self] in
let sortedIndices = Self.multiColumnSortIndices(rows: rows, sortColumns: sortColumns)
let sortedIndices = Self.multiColumnSortIndices(
rows: rows,
sortColumns: sortColumns,
columnTypes: colTypes
)
let sortDuration = Date().timeIntervalSince(sortStartTime)

await MainActor.run { [weak self] in
Expand Down Expand Up @@ -1194,37 +1199,35 @@ final class MainContentCoordinator {
/// Returns an array of indices into the original `rows` array, sorted by the given columns.
nonisolated private static func multiColumnSortIndices(
rows: [[String?]],
sortColumns: [SortColumn]
sortColumns: [SortColumn],
columnTypes: [ColumnType] = []
) -> [Int] {
// Fast path: single-column sort avoids intermediate key array allocation
if sortColumns.count == 1 {
let col = sortColumns[0]
let colIndex = col.columnIndex
let ascending = col.direction == .ascending
let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil
var indices = Array(0..<rows.count)
indices.sort { i1, i2 in
let v1 = colIndex < rows[i1].count ? (rows[i1][colIndex] ?? "") : ""
let v2 = colIndex < rows[i2].count ? (rows[i2][colIndex] ?? "") : ""
let cmp = v1.localizedStandardCompare(v2)
let cmp = RowSortComparator.compare(v1, v2, columnType: colType)
return ascending ? cmp == .orderedAscending : cmp == .orderedDescending
}
return indices
}

// Pre-extract sort keys for each row to avoid repeated access during comparison
let sortKeys: [[String]] = rows.map { row in
sortColumns.map { sortCol in
sortCol.columnIndex < row.count
? (row[sortCol.columnIndex] ?? "") : ""
}
}

var indices = Array(0..<rows.count)
indices.sort { i1, i2 in
let keys1 = sortKeys[i1]
let keys2 = sortKeys[i2]
for (colIdx, sortCol) in sortColumns.enumerated() {
let result = keys1[colIdx].localizedStandardCompare(keys2[colIdx])
let row1 = rows[i1]
let row2 = rows[i2]
for sortCol in sortColumns {
let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : ""
let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : ""
let colType = sortCol.columnIndex < columnTypes.count
? columnTypes[sortCol.columnIndex] : nil
let result = RowSortComparator.compare(v1, v2, columnType: colType)
if result == .orderedSame { continue }
return sortCol.direction == .ascending
? result == .orderedAscending
Expand Down
35 changes: 35 additions & 0 deletions TableProTests/Utilities/MemoryPressureAdvisorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// MemoryPressureAdvisorTests.swift
// TableProTests
//

import Testing
@testable import TablePro

@Suite("MemoryPressureAdvisor")
struct MemoryPressureAdvisorTests {
@Test("budget returns positive value")
func budgetPositive() {
let budget = MemoryPressureAdvisor.budgetForInactiveTabs()
#expect(budget >= 2)
#expect(budget <= 8)
}

@Test("memory estimation for typical tab")
func typicalTabEstimate() {
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 1000, columnCount: 10)
#expect(bytes == 640_000)
}

@Test("memory estimation for empty tab")
func emptyTabEstimate() {
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 0, columnCount: 10)
#expect(bytes == 0)
}

@Test("memory estimation for large tab")
func largeTabEstimate() {
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 50_000, columnCount: 20)
#expect(bytes == 64_000_000)
}
}
71 changes: 71 additions & 0 deletions TableProTests/Utilities/RowSortComparatorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// RowSortComparatorTests.swift
// TableProTests
//

import Foundation
import Testing
@testable import TablePro

@Suite("RowSortComparator")
struct RowSortComparatorTests {
@Test("numeric string ordering treats 10 > 2")
func numericOrdering() {
let result = RowSortComparator.compare("10", "2", columnType: nil)
#expect(result == .orderedDescending)
}

@Test("integer column uses Int64 comparison")
func integerColumn() {
let result = RowSortComparator.compare("-5", "3", columnType: .integer(rawType: "INT"))
#expect(result == .orderedAscending)
}

@Test("integer column with large values")
func integerLargeValues() {
let result = RowSortComparator.compare("999999999", "1000000000", columnType: .integer(rawType: "BIGINT"))
#expect(result == .orderedAscending)
}

@Test("integer column with non-numeric falls back to string")
func integerFallback() {
let result = RowSortComparator.compare("abc", "def", columnType: .integer(rawType: "INT"))
#expect(result == .orderedAscending)
}

@Test("decimal column uses Double comparison")
func decimalColumn() {
let result = RowSortComparator.compare("1.5", "2.3", columnType: .decimal(rawType: "DECIMAL"))
#expect(result == .orderedAscending)
}

@Test("decimal column negative values")
func decimalNegative() {
let result = RowSortComparator.compare("-1.5", "0.5", columnType: .decimal(rawType: "FLOAT"))
#expect(result == .orderedAscending)
}

@Test("equal values return orderedSame")
func equalValues() {
let result = RowSortComparator.compare("hello", "hello", columnType: nil)
#expect(result == .orderedSame)
}

@Test("empty strings are equal")
func emptyStrings() {
let result = RowSortComparator.compare("", "", columnType: nil)
#expect(result == .orderedSame)
}

@Test("text column uses numeric string comparison")
func textColumn() {
let result = RowSortComparator.compare("file2", "file10", columnType: .text(rawType: "VARCHAR"))
#expect(result == .orderedAscending)
}

@Test("nil column type uses numeric string comparison")
func nilColumnType() {
let result = RowSortComparator.compare("10", "2", columnType: nil)
#expect(result == .orderedDescending)
}
}
18 changes: 9 additions & 9 deletions TableProTests/Views/Results/DataGridPerformanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ struct SortKeyCachingTests {
}

var indices1 = Array(0..<rows.count)
indices1.sort { keys[$0].localizedStandardCompare(keys[$1]) == .orderedAscending }
indices1.sort { keys[$0].compare(keys[$1], options: [.numeric]) == .orderedAscending }

var indices2 = Array(0..<rows.count)
indices2.sort {
let v1 = sortColumnIndex < rows[$0].count ? (rows[$0][sortColumnIndex] ?? "") : ""
let v2 = sortColumnIndex < rows[$1].count ? (rows[$1][sortColumnIndex] ?? "") : ""
return v1.localizedStandardCompare(v2) == .orderedAscending
return RowSortComparator.compare(v1, v2, columnType: nil) == .orderedAscending
}

#expect(indices1 == indices2)
Expand All @@ -42,17 +42,17 @@ struct SortKeyCachingTests {
["Bob", "35"],
]

let sortKeys: [[String]] = rows.map { row in
[row[0] ?? "", row[1] ?? ""]
}

var indices = Array(0..<rows.count)
indices.sort { i1, i2 in
let result = sortKeys[i1][0].localizedStandardCompare(sortKeys[i2][0])
let v1 = rows[i1][0] ?? ""
let v2 = rows[i2][0] ?? ""
let result = RowSortComparator.compare(v1, v2, columnType: nil)
if result != .orderedSame {
return result == .orderedAscending
}
let result2 = sortKeys[i1][1].localizedStandardCompare(sortKeys[i2][1])
let w1 = rows[i1][1] ?? ""
let w2 = rows[i2][1] ?? ""
let result2 = RowSortComparator.compare(w1, w2, columnType: nil)
return result2 == .orderedDescending
}

Expand All @@ -78,7 +78,7 @@ struct SortKeyCachingTests {
}

var indices = Array(0..<rows.count)
indices.sort { keys[$0].localizedStandardCompare(keys[$1]) == .orderedAscending }
indices.sort { keys[$0].compare(keys[$1], options: [.numeric]) == .orderedAscending }

// Empty string (nil) sorts first, then Alice, then Charlie
#expect(rows[indices[0]][0] == nil)
Expand Down
Loading