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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Show schemas as collapsible sections in the Tables sidebar (#1296)
- Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283)
- Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283)
- CSV/TSV inspector: open files from Finder or File > Open, edit cells, multi-condition filter (Cmd+F), multi-column sort (shift-click headers), add/remove/rename columns with type override, copy/paste rows as TSV, undo/redo, auto-reload on external changes. Large files stream from disk without loading into memory. (#1259)
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ protocol DatabaseDriver: AnyObject {
/// Fetch all tables in the database
func fetchTables() async throws -> [TableInfo]

func fetchTables(schema: String?) async throws -> [TableInfo]

/// Fetch columns for a specific table
func fetchColumns(table: String) async throws -> [ColumnInfo]

Expand Down Expand Up @@ -359,6 +361,10 @@ extension DatabaseDriver {
/// Default: no schema support (MySQL/SQLite don't use schemas in the same way)
func fetchSchemas() async throws -> [String] { [] }

func fetchTables(schema: String?) async throws -> [TableInfo] {
try await fetchTables()
}

func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { [] }

func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { [] }
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Events/AppEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ final class AppEvents {

let editorSettingsChanged = PassthroughSubject<Void, Never>()

let sidebarSettingsChanged = PassthroughSubject<Void, Never>()

let dataGridSettingsChanged = PassthroughSubject<Void, Never>()

let aiSettingsChanged = PassthroughSubject<Void, Never>()
Expand Down
49 changes: 31 additions & 18 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,25 +160,38 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {

func fetchTables() async throws -> [TableInfo] {
let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema)
return pluginTables.map { table in
let tableType: TableInfo.TableType
switch table.type.lowercased() {
case "table", "base table", "prefix":
tableType = .table
case "view":
tableType = .view
case "materialized view", "materialized_view":
tableType = .materializedView
case "foreign table", "foreign_table":
tableType = .foreignTable
case "system table", "system base table", "system view":
tableType = .systemTable
default:
Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table")
tableType = .table
}
return TableInfo(name: table.name, type: tableType, rowCount: table.rowCount)
return pluginTables.map { mapPluginTable($0, schemaFallback: nil) }
}

func fetchTables(schema: String?) async throws -> [TableInfo] {
let resolvedSchema = schema ?? pluginDriver.currentSchema
let pluginTables = try await pluginDriver.fetchTables(schema: resolvedSchema)
return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) }
}

private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?) -> TableInfo {
let tableType: TableInfo.TableType
switch table.type.lowercased() {
case "table", "base table", "prefix":
tableType = .table
case "view":
tableType = .view
case "materialized view", "materialized_view":
tableType = .materializedView
case "foreign table", "foreign_table":
tableType = .foreignTable
case "system table", "system base table", "system view":
tableType = .systemTable
default:
Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table")
tableType = .table
}
return TableInfo(
name: table.name,
type: tableType,
rowCount: table.rowCount,
schema: table.schema ?? schemaFallback
)
}

func fetchColumns(table: String) async throws -> [ColumnInfo] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
previewCoordinator.promotePreviewTab()
} else {
previewCoordinator.promotePreviewTab()
coordinator.openTableTab(table.name, isView: isView)
coordinator.openTableTab(table.name, schema: table.schema, isView: isView)
}
} else {
coordinator.promotePreviewTab()
coordinator.openTableTab(table.name, isView: isView)
coordinator.openTableTab(table.name, schema: table.schema, isView: isView)
}
},
pendingTruncates: sessionPendingTruncatesBinding,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,15 @@ enum SessionStateFactory {
try tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? activeDatabaseName
databaseName: payload.databaseName ?? activeDatabaseName,
schemaName: payload.schemaName
)
} else {
try tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? activeDatabaseName
databaseName: payload.databaseName ?? activeDatabaseName,
schemaName: payload.schemaName
)
}
} catch {
Expand Down
165 changes: 162 additions & 3 deletions TablePro/Core/Services/Query/SchemaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// TablePro
//

import Combine
import Foundation
import os

Expand All @@ -14,14 +15,26 @@ final class SchemaService {
private(set) var states: [UUID: SchemaState] = [:]
private(set) var procedures: [UUID: [RoutineInfo]] = [:]
private(set) var functions: [UUID: [RoutineInfo]] = [:]
private(set) var schemasInOrder: [UUID: [String]] = [:]

@ObservationIgnored private var lastLoadDates: [UUID: Date] = [:]
@ObservationIgnored private let loadDedup = OnceTask<UUID, [TableInfo]>()
@ObservationIgnored private let procedureDedup = OnceTask<UUID, [RoutineInfo]>()
@ObservationIgnored private let functionDedup = OnceTask<UUID, [RoutineInfo]>()
@ObservationIgnored private let schemasDedup = OnceTask<UUID, [String]>()
@ObservationIgnored private var settingsCancellable: AnyCancellable?
@ObservationIgnored private var lastDisplaySchemas: Bool = false
@ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService")

init() {}
init() {
lastDisplaySchemas = AppSettingsManager.shared.sidebar.displaySchemas
settingsCancellable = AppEvents.shared.sidebarSettingsChanged
.sink { [weak self] in
Task { @MainActor [weak self] in
self?.handleSidebarSettingsChange()
}
}
}

func state(for connectionId: UUID) -> SchemaState {
states[connectionId] ?? .idle
Expand All @@ -46,6 +59,10 @@ final class SchemaService {
procedures(for: connectionId) + functions(for: connectionId)
}

func schemas(for connectionId: UUID) -> [String] {
schemasInOrder[connectionId] ?? []
}

func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async {
switch state(for: connectionId) {
case .loaded:
Expand Down Expand Up @@ -74,9 +91,13 @@ final class SchemaService {
}

func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async {
let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver)
do {
let routines = try await procedureDedup.execute(key: connectionId) {
try await driver.fetchProcedures(schema: nil)
if let schemas = visibleSchemas {
return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .procedure)
}
return try await driver.fetchProcedures(schema: nil)
}
procedures[connectionId] = routines
} catch is CancellationError {
Expand All @@ -89,9 +110,13 @@ final class SchemaService {
}

func reloadFunctions(connectionId: UUID, driver: DatabaseDriver) async {
let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver)
do {
let routines = try await functionDedup.execute(key: connectionId) {
try await driver.fetchFunctions(schema: nil)
if let schemas = visibleSchemas {
return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .function)
}
return try await driver.fetchFunctions(schema: nil)
}
functions[connectionId] = routines
} catch is CancellationError {
Expand All @@ -107,9 +132,11 @@ final class SchemaService {
await loadDedup.cancel(key: connectionId)
await procedureDedup.cancel(key: connectionId)
await functionDedup.cancel(key: connectionId)
await schemasDedup.cancel(key: connectionId)
states.removeValue(forKey: connectionId)
procedures.removeValue(forKey: connectionId)
functions.removeValue(forKey: connectionId)
schemasInOrder.removeValue(forKey: connectionId)
lastLoadDates.removeValue(forKey: connectionId)
}

Expand All @@ -120,6 +147,23 @@ final class SchemaService {
) async {
states[connectionId] = .loading

let wantsGrouping = AppSettingsManager.shared.sidebar.displaySchemas
&& PluginManager.shared.supportsSchemaSwitching(for: connection.type)

if wantsGrouping {
await runSchemaGroupedLoad(connectionId: connectionId, driver: driver, connection: connection)
} else {
await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection)
}
}

private func runFlatLoad(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection
) async {
schemasInOrder.removeValue(forKey: connectionId)

async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) {
try await driver.fetchTables()
}
Expand Down Expand Up @@ -155,6 +199,104 @@ final class SchemaService {
}
}

private func runSchemaGroupedLoad(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection
) async {
let dbType = connection.type
let allSchemas: [String]
do {
allSchemas = try await schemasDedup.execute(key: connectionId) {
try await driver.fetchSchemas()
}
} catch is CancellationError {
return
} catch {
Self.logger.warning(
"[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public); falling back to flat load"
)
await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection)
return
}

let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: dbType))
let visibleSchemas = allSchemas.filter { !systemSchemas.contains($0) }
schemasInOrder[connectionId] = visibleSchemas

async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) {
try await Self.fetchTablesAcrossSchemas(driver: driver, schemas: visibleSchemas)
}
async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely(
connectionId: connectionId,
kind: .procedure,
dedup: procedureDedup,
fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .procedure) }
)
async let functionsTask: [RoutineInfo] = Self.fetchRoutinesSafely(
connectionId: connectionId,
kind: .function,
dedup: functionDedup,
fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .function) }
)

let loadedProcedures = await proceduresTask
let loadedFunctions = await functionsTask

do {
let tables = try await tablesTask
states[connectionId] = .loaded(tables)
procedures[connectionId] = loadedProcedures
functions[connectionId] = loadedFunctions
lastLoadDates[connectionId] = Date()
} catch is CancellationError {
return
} catch {
Self.logger.warning(
"[schema] grouped load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
states[connectionId] = .failed(error.localizedDescription)
}
}

private func visibleSchemasForGroupedReload(connectionId: UUID, driver: DatabaseDriver) -> [String]? {
guard AppSettingsManager.shared.sidebar.displaySchemas else { return nil }
let schemas = schemasInOrder[connectionId] ?? []
guard !schemas.isEmpty else { return nil }
return schemas
}

private static func fetchTablesAcrossSchemas(
driver: DatabaseDriver,
schemas: [String]
) async throws -> [TableInfo] {
var aggregated: [TableInfo] = []
for schema in schemas {
try Task.checkCancellation()
let tables = try await driver.fetchTables(schema: schema)
aggregated.append(contentsOf: tables)
}
return aggregated
}

private static func fetchRoutinesAcrossSchemas(
driver: DatabaseDriver,
schemas: [String],
kind: RoutineInfo.Kind
) async throws -> [RoutineInfo] {
var aggregated: [RoutineInfo] = []
for schema in schemas {
try Task.checkCancellation()
let routines: [RoutineInfo]
switch kind {
case .procedure: routines = try await driver.fetchProcedures(schema: schema)
case .function: routines = try await driver.fetchFunctions(schema: schema)
}
aggregated.append(contentsOf: routines)
}
return aggregated
}

private static func fetchRoutinesSafely(
connectionId: UUID,
kind: RoutineInfo.Kind,
Expand All @@ -172,4 +314,21 @@ final class SchemaService {
return []
}
}

private func handleSidebarSettingsChange() {
let now = AppSettingsManager.shared.sidebar.displaySchemas
guard now != lastDisplaySchemas else { return }
lastDisplaySchemas = now

let sessions = DatabaseManager.shared.activeSessions
for (connectionId, session) in sessions {
guard let driver = session.driver else { continue }
let connection = session.connection
Task { [weak self] in
guard let self else { return }
await self.invalidate(connectionId: connectionId)
await self.reload(connectionId: connectionId, driver: driver, connection: connection)
}
}
}
}
9 changes: 9 additions & 0 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ final class AppSettingsManager {
}
}

var sidebar: SidebarSettings {
didSet {
storage.saveSidebar(sidebar)
appEvents.sidebarSettingsChanged.send(())
syncTracker.markDirty(.settings, id: "sidebar")
}
}

var keyboard: KeyboardSettings {
didSet {
storage.saveKeyboard(keyboard)
Expand Down Expand Up @@ -203,6 +211,7 @@ final class AppSettingsManager {
self.dataGrid = storage.loadDataGrid()
self.history = storage.loadHistory()
self.tabs = storage.loadTabs()
self.sidebar = storage.loadSidebar()
self.keyboard = storage.loadKeyboard()
self.ai = Self.migrateAI(storage.loadAI())
self.sync = storage.loadSync()
Expand Down
Loading
Loading