Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Replaced ~40 hardcoded `DatabaseType` switches across ~20 UI files with dynamic plugin property lookups via `PluginManager`, so third-party plugins get correct UI behavior (colors, labels, editor language, feature toggles) automatically
- Replaced `AppState.isMongoDB`/`isRedis` booleans with `AppState.editorLanguage: EditorLanguage` for extensible editor language detection
- Theme colors now derived from plugin `brandColorHex` instead of hardcoded `Theme.mysqlColor` etc.
- Sidebar labels ("Tables"/"Collections"/"Keys"), toolbar preview labels, and AI prompt language detection now use plugin metadata
- Connection form, database switcher, type picker, file open handler, and toolbar all use plugin lookups for connection mode, authentication, import support, and system database names
- Converted `DatabaseType` from closed enum to string-based struct, enabling future plugin-defined database types
- Moved string literal escaping into plugin drivers via `escapeStringLiteral` on `PluginDatabaseDriver` and `DatabaseDriver` protocols; `SQLEscaping.escapeStringLiteral` now uses ANSI SQL escaping only (doubles single quotes, strips null bytes)
- SQL autocomplete data types and CREATE TABLE options now use plugin-provided dialect data instead of hardcoded per-database switches
Expand Down
2 changes: 2 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsForeignKeys = false
static let supportsSchemaEditing = false
static let systemDatabaseNames: [String] = ["admin", "local", "config"]
static let tableEntityName = "Collections"
static let supportsForeignKeyDisable = false
static let databaseGroupingStrategy: GroupingStrategy = .flat
static let columnTypesByCategory: [String: [String]] = [
"String": ["string", "objectId", "regex"],
Expand Down
1 change: 1 addition & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin {

// MARK: - UI/Capability Metadata

static let supportsForeignKeyDisable = false
static let brandColorHex = "#C3160B"
static let systemDatabaseNames: [String] = ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"]
static let databaseGroupingStrategy: GroupingStrategy = .bySchema
Expand Down
3 changes: 3 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
"XML": ["XML"]
]

static let supportsCascadeDrop = true
static let supportsForeignKeyDisable = false

static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "\"",
keywords: [
Expand Down
2 changes: 2 additions & 0 deletions Plugins/RedisDriverPlugin/RedisPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsSchemaEditing = false
static let supportsDatabaseSwitching = false
static let supportsImport = false
static let tableEntityName = "Keys"
static let supportsForeignKeyDisable = false
static let databaseGroupingStrategy: GroupingStrategy = .flat
static let defaultGroupName = "db0"
static let columnTypesByCategory: [String: [String]] = [
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public protocol DriverPlugin: TableProPlugin {
static var columnTypesByCategory: [String: [String]] { get }
static var sqlDialect: SQLDialectDescriptor? { get }
static var statementCompletions: [CompletionEntry] { get }
static var tableEntityName: String { get }
static var supportsCascadeDrop: Bool { get }
static var supportsForeignKeyDisable: Bool { get }
}

public extension DriverPlugin {
Expand Down Expand Up @@ -76,4 +79,7 @@ public extension DriverPlugin {
}
static var sqlDialect: SQLDialectDescriptor? { nil }
static var statementCompletions: [CompletionEntry] { [] }
static var tableEntityName: String { "Tables" }
static var supportsCascadeDrop: Bool { false }
static var supportsForeignKeyDisable: Bool { true }
}
47 changes: 47 additions & 0 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum QueuedURLEntry {
case databaseURL(URL)
case sqliteFile(URL)
case duckdbFile(URL)
case genericDatabaseFile(URL, DatabaseType)
}

extension AppDelegate {
Expand Down Expand Up @@ -172,6 +173,51 @@ extension AppDelegate {
}
}

// MARK: - Generic Database File Handler

func handleGenericDatabaseFile(_ url: URL, type dbType: DatabaseType) {
guard WindowOpener.shared.openWindow != nil else {
queuedURLEntries.append(.genericDatabaseFile(url, dbType))
scheduleQueuedURLProcessing()
return
}

let filePath = url.path(percentEncoded: false)
let connectionName = url.deletingPathExtension().lastPathComponent

for (sessionId, session) in DatabaseManager.shared.activeSessions {
if session.connection.type == dbType
&& session.connection.database == filePath
&& session.driver != nil {
bringConnectionWindowToFront(sessionId)
return
}
}

let connection = DatabaseConnection(
name: connectionName,
host: "",
port: 0,
database: filePath,
username: "",
type: dbType
)

openNewConnectionWindow(for: connection)

Task { @MainActor in
do {
try await DatabaseManager.shared.connectToSession(connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
} catch {
connectionLogger.error("File open failed for '\(filePath, privacy: .public)' (\(dbType.rawValue)): \(error.localizedDescription)")
await self.handleConnectionFailure(error)
}
}
}

// MARK: - Unified Queue

func scheduleQueuedURLProcessing() {
Expand Down Expand Up @@ -203,6 +249,7 @@ extension AppDelegate {
case .databaseURL(let url): self.handleDatabaseURL(url)
case .sqliteFile(let url): self.handleSQLiteFile(url)
case .duckdbFile(let url): self.handleDuckDBFile(url)
case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType)
}
}
self.scheduleWelcomeWindowSuppression()
Expand Down
48 changes: 19 additions & 29 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,21 @@ private let fileOpenLogger = Logger(subsystem: "com.TablePro", category: "FileOp
extension AppDelegate {
// MARK: - URL Classification

private static let databaseURLSchemes: Set<String> = [
"postgresql", "postgres", "mysql", "mariadb", "sqlite",
"mongodb", "mongodb+srv", "redis", "rediss", "redshift",
"mssql", "sqlserver", "oracle", "duckdb"
]

static let sqliteFileExtensions: Set<String> = [
"sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb"
]

static let duckdbFileExtensions: Set<String> = ["duckdb", "ddb"]

private func isDatabaseURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
let base = scheme
.replacingOccurrences(of: "+ssh", with: "")
.replacingOccurrences(of: "+srv", with: "")
return Self.databaseURLSchemes.contains(base) || Self.databaseURLSchemes.contains(scheme)
let registeredSchemes = PluginManager.shared.allRegisteredURLSchemes
return registeredSchemes.contains(base) || registeredSchemes.contains(scheme)
}

private func isSQLiteFile(_ url: URL) -> Bool {
Self.sqliteFileExtensions.contains(url.pathExtension.lowercased())
private func isDatabaseFile(_ url: URL) -> Bool {
PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()] != nil
}

private func isDuckDBFile(_ url: URL) -> Bool {
Self.duckdbFileExtensions.contains(url.pathExtension.lowercased())
private func databaseTypeForFile(_ url: URL) -> DatabaseType? {
PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// MARK: - Main Dispatch
Expand Down Expand Up @@ -68,20 +57,21 @@ extension AppDelegate {
}
}

let sqliteFiles = urls.filter { isSQLiteFile($0) }
if !sqliteFiles.isEmpty {
suppressWelcomeWindow()
Task { @MainActor in
for url in sqliteFiles { self.handleSQLiteFile(url) }
self.scheduleWelcomeWindowSuppression()
}
}

let duckdbFiles = urls.filter { isDuckDBFile($0) }
if !duckdbFiles.isEmpty {
let databaseFiles = urls.filter { isDatabaseFile($0) }
if !databaseFiles.isEmpty {
suppressWelcomeWindow()
Task { @MainActor in
for url in duckdbFiles { self.handleDuckDBFile(url) }
for url in databaseFiles {
guard let dbType = self.databaseTypeForFile(url) else { continue }
switch dbType {
case .sqlite:
self.handleSQLiteFile(url)
case .duckdb:
self.handleDuckDBFile(url)
default:
self.handleGenericDatabaseFile(url, type: dbType)
}
}
self.scheduleWelcomeWindowSuppression()
}
}
Expand Down
36 changes: 21 additions & 15 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import AppKit
import os
import SwiftUI
import TableProPluginKit

struct ContentView: View {
private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView")
Expand Down Expand Up @@ -40,11 +41,8 @@ struct ContentView: View {
defaultTitle = tableName
} else if let connectionId = payload?.connectionId,
let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) {
switch connection.type {
case .mongodb: defaultTitle = "MQL Query"
case .redis: defaultTitle = "Redis CLI"
default: defaultTitle = "SQL Query"
}
let langName = PluginManager.shared.queryLanguageName(for: connection.type)
defaultTitle = langName == "SQL" ? "SQL Query" : langName == "MQL" ? "MQL Query" : langName
} else {
defaultTitle = "SQL Query"
}
Expand Down Expand Up @@ -94,8 +92,10 @@ struct ContentView: View {
}
AppState.shared.isConnected = true
AppState.shared.safeModeLevel = session.connection.safeModeLevel
AppState.shared.isMongoDB = session.connection.type == .mongodb
AppState.shared.isRedis = session.connection.type == .redis
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type)
AppState.shared.currentDatabaseType = session.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: session.connection.type)
}
} else {
currentSession = nil
Expand All @@ -119,8 +119,9 @@ struct ContentView: View {
columnVisibility = .detailOnly
AppState.shared.isConnected = false
AppState.shared.safeModeLevel = .silent
AppState.shared.isMongoDB = false
AppState.shared.isRedis = false
AppState.shared.editorLanguage = .sql
AppState.shared.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true

// Close all native tab windows for this connection and
// force AppKit to deallocate them instead of pooling.
Expand Down Expand Up @@ -150,8 +151,10 @@ struct ContentView: View {
}
AppState.shared.isConnected = true
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
AppState.shared.isMongoDB = newSession.connection.type == .mongodb
AppState.shared.isRedis = newSession.connection.type == .redis
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
AppState.shared.currentDatabaseType = newSession.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: newSession.connection.type)
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
// Only process notifications for our own window to avoid every
Expand All @@ -178,13 +181,16 @@ struct ContentView: View {
if let session = DatabaseManager.shared.activeSessions[connectionId] {
AppState.shared.isConnected = true
AppState.shared.safeModeLevel = session.connection.safeModeLevel
AppState.shared.isMongoDB = session.connection.type == .mongodb
AppState.shared.isRedis = session.connection.type == .redis
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: session.connection.type)
AppState.shared.currentDatabaseType = session.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: session.connection.type)
} else {
AppState.shared.isConnected = false
AppState.shared.safeModeLevel = .silent
AppState.shared.isMongoDB = false
AppState.shared.isRedis = false
AppState.shared.editorLanguage = .sql
AppState.shared.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true
}
}
}
Expand Down
19 changes: 7 additions & 12 deletions TablePro/Core/AI/AIPromptTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,26 @@ import Foundation
/// Centralized prompt templates for AI-powered editor features
enum AIPromptTemplates {
/// Build a prompt asking AI to explain a query
static func explainQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String {
@MainActor static func explainQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String {
let (typeName, lang) = queryInfo(for: databaseType)
return "Explain this \(typeName):\n\n```\(lang)\n\(query)\n```"
}

/// Build a prompt asking AI to optimize a query
static func optimizeQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String {
@MainActor static func optimizeQuery(_ query: String, databaseType: DatabaseType = .mysql) -> String {
let (typeName, lang) = queryInfo(for: databaseType)
return "Optimize this \(typeName) for better performance:\n\n```\(lang)\n\(query)\n```"
}

/// Build a prompt asking AI to fix a query that produced an error
static func fixError(query: String, error: String, databaseType: DatabaseType = .mysql) -> String {
@MainActor static func fixError(query: String, error: String, databaseType: DatabaseType = .mysql) -> String {
let (typeName, lang) = queryInfo(for: databaseType)
return "This \(typeName) failed with an error. Please fix it.\n\nQuery:\n```\(lang)\n\(query)\n```\n\nError: \(error)"
}

private static func queryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) {
switch databaseType {
case .mongodb:
return ("MongoDB query", "javascript")
case .redis:
return ("Redis command", "bash")
default:
return ("SQL query", "sql")
}
@MainActor private static func queryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) {
let langName = PluginManager.shared.queryLanguageName(for: databaseType)
let lang = PluginManager.shared.editorLanguage(for: databaseType).codeBlockTag
return ("\(langName) query", lang)
}
}
38 changes: 16 additions & 22 deletions TablePro/Core/AI/AISchemaContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation
import os
import TableProPluginKit

/// Builds schema context for AI system prompts
struct AISchemaContext {
Expand All @@ -18,7 +19,7 @@ struct AISchemaContext {
// MARK: - Public

/// Build a system prompt including database context
static func buildSystemPrompt(
@MainActor static func buildSystemPrompt(
databaseType: DatabaseType,
databaseName: String,
tables: [TableInfo],
Expand Down Expand Up @@ -55,12 +56,7 @@ struct AISchemaContext {
if settings.includeCurrentQuery,
let query = currentQuery,
!query.isEmpty {
let lang: String
switch databaseType {
case .mongodb: lang = "javascript"
case .redis: lang = "bash"
default: lang = "sql"
}
let lang = PluginManager.shared.editorLanguage(for: databaseType).codeBlockTag
parts.append("\n## Current Query\n```\(lang)\n\(query)\n```")
}

Expand All @@ -70,21 +66,12 @@ struct AISchemaContext {
parts.append("\n## Recent Query Results\n\(results)")
}

if databaseType == .mongodb {
parts.append(
"\nProvide MongoDB shell queries using `javascript` fenced code blocks."
)
parts.append(
"Use MongoDB shell syntax (db.collection.find(), etc.), not SQL."
)
} else if databaseType == .redis {
parts.append(
"\nProvide Redis commands using `bash` fenced code blocks."
)
parts.append(
"Use Redis CLI syntax (GET, SET, HGETALL, etc.), not SQL."
)
} else {
let editorLang = PluginManager.shared.editorLanguage(for: databaseType)
let langName = PluginManager.shared.queryLanguageName(for: databaseType)
let langTag = editorLang.codeBlockTag

switch editorLang {
case .sql:
parts.append(
"\nProvide SQL queries appropriate for"
+ " \(databaseType.rawValue) syntax when applicable."
Expand All @@ -93,6 +80,13 @@ struct AISchemaContext {
"When writing SQL, use the correct identifier quoting"
+ " for \(databaseType.rawValue)."
)
default:
parts.append(
"\nProvide \(langName) queries using `\(langTag)` fenced code blocks."
)
parts.append(
"Use \(langName) syntax, not SQL."
)
}

return parts.joined(separator: "\n")
Expand Down
Loading
Loading