Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,6 @@ Libs/*.a
Libs/.downloaded
Libs/dylibs/
Libs/ios/

# Local refactor scratchpad (per chore: untrack docs/refactor scratchpad)
docs/refactor/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR.
- Internal: row data and load epoch now live on `TabSession`. `TabSessionRegistry` exposes the row-access methods directly (`tableRows(for:)`, `setTableRows(_:for:)`, `evict(for:)`, etc.); the intermediate `TableRowsStore` facade is gone. All consumers (coordinator, extensions, views, command actions) now read row data from the registry. No user-visible behavior change.
- Internal: hidden-column state moves from the per-window `ColumnVisibilityManager` into each tab's `columnLayout.hiddenColumns`. The shared manager is removed; `MainContentCoordinator` exposes `hideColumn`, `showColumn`, `toggleColumnVisibility`, `showAllColumns`, `hideAllColumns`, and `pruneHiddenColumns` that mutate the active tab directly. Per-table UserDefaults persistence moves into a small `ColumnVisibilityPersistence` service. Tab-switch save/restore swap is gone — each tab is its own source of truth. No user-visible behavior change.
- Internal: filter state collapses from three places (the per-window `FilterStateManager`, the `TabFilterState` snapshot on `QueryTab`, and the per-table file-based restore) to a single source: `tab.filterState`. The shared manager is removed; `MainContentCoordinator` now exposes the full filter API (`addFilter`, `applyAllFilters`, `clearFilterState`, `toggleFilterPanel`, `setFKFilter`, `saveLastFilters(for:)`, `restoreLastFilters(for:)`, `saveFilterPreset`, `loadFilterPreset`, `generateFilterPreviewSQL`, etc.) that mutates the active tab. The file-based "restore last filters" persistence in `FilterSettingsStorage` is unchanged. `FilterPanelView`, `MainStatusBarView`, `MainContentCommandActions`, `MainContentView`, and `MainEditorContentView` read filter state directly off the active tab. No user-visible behavior change.
- Internal: extract `QueryExecutor` service from `MainContentCoordinator`. Query data fetch, parallel schema fetch, schema parsing, parameter detection, row-cap policy, and DDL detection now live in `TablePro/Core/Services/Query/QueryExecutor.swift`. SQL parsing helpers (`extractTableName`, `stripTrailingOrderBy`, `parseSQLiteCheckConstraintValues`) move into `QuerySqlParser`. Coordinator methods become thin wrappers; behavior unchanged. No user-visible behavior change.

### Fixed

- Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2
- Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `<unsupported: type>` instead of crashing (#965)

## [0.37.0] - 2026-05-01
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
rightPanelState: rightPanelState,
tabManager: sessionState.tabManager,
changeManager: sessionState.changeManager,
filterStateManager: sessionState.filterStateManager,
toolbarState: sessionState.toolbarState,
coordinator: sessionState.coordinator
)
Expand Down
25 changes: 12 additions & 13 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ enum SessionStateFactory {
struct SessionState {
let tabManager: QueryTabManager
let changeManager: DataChangeManager
let filterStateManager: FilterStateManager
let columnVisibilityManager: ColumnVisibilityManager
let toolbarState: ConnectionToolbarState
let coordinator: MainContentCoordinator
}
Expand Down Expand Up @@ -57,13 +55,15 @@ enum SessionStateFactory {
payload: EditorTabPayload?
) -> SessionState {
let connectionId = connection.id
let tabMgr = QueryTabManager(globalTabsProvider: {
MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) }
})
let tabSessionRegistry = TabSessionRegistry()
let tabMgr = QueryTabManager(
globalTabsProvider: {
MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) }
},
tabSessionRegistry: tabSessionRegistry
)
let changeMgr = DataChangeManager()
changeMgr.databaseType = connection.type
let filterMgr = FilterStateManager()
let colVisMgr = ColumnVisibilityManager()
let toolbarSt = ConnectionToolbarState(connection: connection)

if let session = DatabaseManager.shared.session(for: connection.id) {
Expand Down Expand Up @@ -115,7 +115,6 @@ enum SessionStateFactory {
}
if let initialFilter = payload.initialFilterState {
tabMgr.tabs[index].filterState = initialFilter
filterMgr.restoreFromTabState(initialFilter)
}
}
} else {
Expand Down Expand Up @@ -157,13 +156,15 @@ enum SessionStateFactory {
}
}

let queryExecutor = QueryExecutor(connection: connection)

let coord = MainContentCoordinator(
connection: connection,
tabManager: tabMgr,
changeManager: changeMgr,
filterStateManager: filterMgr,
columnVisibilityManager: colVisMgr,
toolbarState: toolbarSt
toolbarState: toolbarSt,
tabSessionRegistry: tabSessionRegistry,
queryExecutor: queryExecutor
)

// Eagerly publish to the active-coordinator registry so concurrent
Expand All @@ -176,8 +177,6 @@ enum SessionStateFactory {
return SessionState(
tabManager: tabMgr,
changeManager: changeMgr,
filterStateManager: filterMgr,
columnVisibilityManager: colVisMgr,
toolbarState: toolbarSt,
coordinator: coord
)
Expand Down
266 changes: 266 additions & 0 deletions TablePro/Core/Services/Query/QueryExecutor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import Foundation
import os
import TableProPluginKit

private let queryExecutorLog = Logger(subsystem: "com.TablePro", category: "QueryExecutor")

struct QueryFetchResult {
let columns: [String]
let columnTypes: [ColumnType]
let rows: [[String?]]
let executionTime: TimeInterval
let rowsAffected: Int
let statusMessage: String?
let isTruncated: Bool
}

typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo], approximateRowCount: Int?)

struct ParsedSchemaMetadata {
let columnDefaults: [String: String?]
let columnForeignKeys: [String: ForeignKeyInfo]
let columnNullable: [String: Bool]
let primaryKeyColumns: [String]
let approximateRowCount: Int?
let columnEnumValues: [String: [String]]
}

struct QueryExecutionResult {
let fetchResult: QueryFetchResult
let schemaResult: SchemaResult?
let parsedMetadata: ParsedSchemaMetadata?
}

@MainActor
final class QueryExecutor {
let connection: DatabaseConnection
var connectionId: UUID { connection.id }

init(connection: DatabaseConnection) {
self.connection = connection
}

// MARK: - Driver access

private func resolveDriver() throws -> DatabaseDriver {
guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
throw DatabaseError.notConnected
}
return driver
}

// MARK: - Public orchestrators

func executeQuery(
sql: String,
parameters: [Any?]? = nil,
rowCap: Int?,
tableName: String?,
fetchSchemaForTable: Bool
) async throws -> QueryExecutionResult {
let connId = connectionId

var parallelSchemaTask: Task<SchemaResult, Error>?
if fetchSchemaForTable, let tableName, !tableName.isEmpty {
parallelSchemaTask = Task {
guard let driver = DatabaseManager.shared.driver(for: connId) else {
throw DatabaseError.notConnected
}
async let cols = driver.fetchColumns(table: tableName)
async let fks = driver.fetchForeignKeys(table: tableName)
let result = try await (columnInfo: cols, fkInfo: fks)
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
return (
columnInfo: result.columnInfo,
fkInfo: result.fkInfo,
approximateRowCount: approxCount
)
}
}

let driver = try resolveDriver()

let fetchResult: QueryFetchResult
do {
if let parameters {
fetchResult = try await Self.fetchQueryDataParameterized(
driver: driver,
sql: sql,
parameters: parameters,
rowCap: rowCap
)
} else {
fetchResult = try await Self.fetchQueryData(
driver: driver,
sql: sql,
rowCap: rowCap
)
}
} catch {
parallelSchemaTask?.cancel()
throw error
}

var schemaResult: SchemaResult?
if fetchSchemaForTable, let tableName, !tableName.isEmpty {
schemaResult = await Self.awaitSchemaResult(
connectionId: connId,
parallelTask: parallelSchemaTask,
tableName: tableName
)
}

let parsedMetadata = schemaResult.map { Self.parseSchemaMetadata($0) }

return QueryExecutionResult(
fetchResult: fetchResult,
schemaResult: schemaResult,
parsedMetadata: parsedMetadata
)
}

// MARK: - Driver fetch (nonisolated, runs on background)

nonisolated static func fetchQueryData(
driver: DatabaseDriver,
sql: String,
rowCap: Int?
) async throws -> QueryFetchResult {
let start = CFAbsoluteTimeGetCurrent()
queryExecutorLog.info("[executeUserQuery] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil")")
let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: nil)
let elapsed = CFAbsoluteTimeGetCurrent() - start
queryExecutorLog.info("[executeUserQuery] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
return QueryFetchResult(
columns: result.columns,
columnTypes: result.columnTypes,
rows: result.rows,
executionTime: result.executionTime,
rowsAffected: result.rowsAffected,
statusMessage: result.statusMessage,
isTruncated: result.isTruncated
)
}

nonisolated static func fetchQueryDataParameterized(
driver: DatabaseDriver,
sql: String,
parameters: [Any?],
rowCap: Int?
) async throws -> QueryFetchResult {
let start = CFAbsoluteTimeGetCurrent()
queryExecutorLog.info("[executeUserQueryParameterized] sql=\(sql.prefix(100), privacy: .public) rowCap=\(rowCap?.description ?? "nil") params=\(parameters.count)")
let result = try await driver.executeUserQuery(query: sql, rowCap: rowCap, parameters: parameters)
let elapsed = CFAbsoluteTimeGetCurrent() - start
queryExecutorLog.info("[executeUserQueryParameterized] rows=\(result.rows.count) truncated=\(result.isTruncated) driverTime=\(String(format: "%.3f", result.executionTime))s totalTime=\(String(format: "%.3f", elapsed))s")
return QueryFetchResult(
columns: result.columns,
columnTypes: result.columnTypes,
rows: result.rows,
executionTime: result.executionTime,
rowsAffected: result.rowsAffected,
statusMessage: result.statusMessage,
isTruncated: result.isTruncated
)
}

// MARK: - Schema await + parse

static func awaitSchemaResult(
connectionId: UUID,
parallelTask: Task<SchemaResult, Error>?,
tableName: String
) async -> SchemaResult? {
if let parallelTask {
return try? await parallelTask.value
}
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil }
do {
async let cols = driver.fetchColumns(table: tableName)
async let fks = driver.fetchForeignKeys(table: tableName)
let (c, f) = try await (cols, fks)
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount)
} catch {
queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}

static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata {
var defaults: [String: String?] = [:]
var fks: [String: ForeignKeyInfo] = [:]
var nullable: [String: Bool] = [:]
for col in schema.columnInfo {
defaults[col.name] = col.defaultValue
nullable[col.name] = col.isNullable
}
for fk in schema.fkInfo {
fks[fk.column] = fk
}
var enumValues: [String: [String]] = [:]
for col in schema.columnInfo {
if let values = ColumnType.parseEnumValues(from: col.dataType) {
enumValues[col.name] = values
} else if let values = ColumnType.parseClickHouseEnumValues(from: col.dataType) {
enumValues[col.name] = values
}
}
return ParsedSchemaMetadata(
columnDefaults: defaults,
columnForeignKeys: fks,
columnNullable: nullable,
primaryKeyColumns: schema.columnInfo.filter { $0.isPrimaryKey }.map(\.name),
approximateRowCount: schema.approximateRowCount,
columnEnumValues: enumValues
)
}

// MARK: - Row cap policy

static func resolveRowCap(sql: String, tabType: TabType, databaseType: DatabaseType) -> Int? {
let dataGridSettings = AppSettingsManager.shared.dataGrid
let trimmedUpper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let isSelectQuery = trimmedUpper.hasPrefix("SELECT ") || trimmedUpper.hasPrefix("WITH ")
let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType)
let isDDL = isDDLStatement(sql)

guard tabType == .query, isSelectQuery, !isWrite, !isDDL,
dataGridSettings.truncateQueryResults
else {
return nil
}
return dataGridSettings.validatedQueryResultRowCap
}

private static let ddlPrefixes: [String] = [
"CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME",
]

static func isDDLStatement(_ sql: String) -> Bool {
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return ddlPrefixes.contains { trimmed.hasPrefix($0) }
}

// MARK: - Parameter detection

static func detectAndReconcileParameters(
sql: String,
existing: [QueryParameter]
) -> [QueryParameter] {
let detectedNames = SQLParameterExtractor.extractParameters(from: sql)
guard !detectedNames.isEmpty else { return [] }

let existingByName = Dictionary(
existing.map { ($0.name, $0) },
uniquingKeysWith: { first, _ in first }
)

return detectedNames.map { name in
if let existing = existingByName[name] {
return existing
}
return QueryParameter(name: name)
}
}
}
Loading
Loading