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 @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `FilterSQLGenerator` now uses `SQLDialectDescriptor` data (regex syntax, boolean literals, LIKE escape style, pagination style) instead of `DatabaseType` switch statements
- Moved identifier quoting, autocomplete statement completions, view templates, and FK disable/enable into plugin system
- Removed `DatabaseType` switches from `FilterSQLGenerator`, `SQLCompletionProvider`, `ImportDataSinkAdapter`, and `MainContentCoordinator+SidebarActions`
- Replaced hardcoded `DatabaseType` switches in ExportDialog, DataChangeManager, SafeModeGuard, ExportService, DataGridView, HighlightedSQLTextView, ForeignKeyPopoverContentView, QueryTab, SQLRowToStatementConverter, SessionStateFactory, ConnectionToolbarState, and DatabaseSwitcherSheet with dynamic plugin lookups (`databaseGroupingStrategy`, `immutableColumns`, `supportsReadOnlyMode`, `paginationStyle`, `editorLanguage`, `connectionMode`, `supportsSchemaSwitching`)

### Added

Expand Down
3 changes: 2 additions & 1 deletion Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
regexSyntax: .match,
booleanLiteralStyle: .numeric,
likeEscapeStyle: .implicit,
paginationStyle: .limit
paginationStyle: .limit,
requiresBackslashEscaping: true
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
Expand Down
1 change: 1 addition & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin {

static let brandColorHex = "#E34517"
static let systemDatabaseNames: [String] = ["master", "tempdb", "model", "msdb"]
static let defaultSchemaName = "dbo"
static let databaseGroupingStrategy: GroupingStrategy = .bySchema
static let columnTypesByCategory: [String: [String]] = [
"Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"],
Expand Down
2 changes: 2 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin {
static let systemDatabaseNames: [String] = ["admin", "local", "config"]
static let tableEntityName = "Collections"
static let supportsForeignKeyDisable = false
static let immutableColumns: [String] = ["_id"]
static let supportsReadOnlyMode = false
static let databaseGroupingStrategy: GroupingStrategy = .flat
static let columnTypesByCategory: [String: [String]] = [
"String": ["string", "objectId", "regex"],
Expand Down
3 changes: 2 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {
regexSyntax: .regexp,
booleanLiteralStyle: .numeric,
likeEscapeStyle: .implicit,
paginationStyle: .limit
paginationStyle: .limit,
requiresBackslashEscaping: true
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
Expand Down
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let urlSchemes: [String] = ["postgresql", "postgres"]
static let brandColorHex = "#336791"
static let systemDatabaseNames: [String] = ["postgres", "template0", "template1"]
static let supportsSchemaSwitching = true
static let databaseGroupingStrategy: GroupingStrategy = .bySchema
static let columnTypesByCategory: [String: [String]] = [
"Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"],
Expand Down
1 change: 1 addition & 0 deletions Plugins/RedisDriverPlugin/RedisPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsImport = false
static let tableEntityName = "Keys"
static let supportsForeignKeyDisable = false
static let supportsReadOnlyMode = 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 @@ -39,6 +39,9 @@ public protocol DriverPlugin: TableProPlugin {
static var tableEntityName: String { get }
static var supportsCascadeDrop: Bool { get }
static var supportsForeignKeyDisable: Bool { get }
static var immutableColumns: [String] { get }
static var supportsReadOnlyMode: Bool { get }
static var defaultSchemaName: String { get }
}

public extension DriverPlugin {
Expand Down Expand Up @@ -82,4 +85,7 @@ public extension DriverPlugin {
static var tableEntityName: String { "Tables" }
static var supportsCascadeDrop: Bool { false }
static var supportsForeignKeyDisable: Bool { true }
static var immutableColumns: [String] { [] }
static var supportsReadOnlyMode: Bool { true }
static var defaultSchemaName: String { "public" }
}
5 changes: 4 additions & 1 deletion Plugins/TableProPluginKit/SQLDialectDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public struct SQLDialectDescriptor: Sendable {
public let likeEscapeStyle: LikeEscapeStyle
public let paginationStyle: PaginationStyle
public let offsetFetchOrderBy: String
public let requiresBackslashEscaping: Bool

public enum RegexSyntax: String, Sendable {
case regexp // MySQL: column REGEXP 'pattern'
Expand Down Expand Up @@ -57,7 +58,8 @@ public struct SQLDialectDescriptor: Sendable {
booleanLiteralStyle: BooleanLiteralStyle = .numeric,
likeEscapeStyle: LikeEscapeStyle = .explicit,
paginationStyle: PaginationStyle = .limit,
offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)"
offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)",
requiresBackslashEscaping: Bool = false
) {
self.identifierQuote = identifierQuote
self.keywords = keywords
Expand All @@ -69,5 +71,6 @@ public struct SQLDialectDescriptor: Sendable {
self.likeEscapeStyle = likeEscapeStyle
self.paginationStyle = paginationStyle
self.offsetFetchOrderBy = offsetFetchOrderBy
self.requiresBackslashEscaping = requiresBackslashEscaping
}
}
14 changes: 1 addition & 13 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -647,24 +647,12 @@ final class DataChangeManager {
deletedRowIndices: deletedRowIndices,
insertedRowIndices: insertedRowIndices
) {
// Validate MongoDB _id requirement
if databaseType == .mongodb {
let expectedUpdates = changes.count(where: { $0.type == .update })
let actualUpdates = statements.count(where: { $0.statement.contains("updateOne(") || $0.statement.contains("updateMany(") })

if expectedUpdates > 0 && actualUpdates < expectedUpdates {
throw DatabaseError.queryFailed(
"Cannot save UPDATE changes to collection '\(tableName)' without an _id field. " +
"Please ensure the collection has _id values."
)
}
}
return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters) }
}
}

// Safety: prevent SQL generation for NoSQL databases if plugin driver is unavailable
if databaseType == .mongodb || databaseType == .redis {
if PluginManager.shared.editorLanguage(for: databaseType) != .sql {
throw DatabaseError.queryFailed(
"Cannot generate statements for \(databaseType.rawValue) — plugin driver not initialized"
)
Expand Down
33 changes: 33 additions & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,39 @@ final class PluginManager {
return Swift.type(of: plugin).supportsForeignKeyDisable
}

func immutableColumns(for databaseType: DatabaseType) -> [String] {
guard let plugin = driverPlugin(for: databaseType) else { return [] }
return Swift.type(of: plugin).immutableColumns
}

func supportsReadOnlyMode(for databaseType: DatabaseType) -> Bool {
guard let plugin = driverPlugin(for: databaseType) else { return true }
return Swift.type(of: plugin).supportsReadOnlyMode
}

func defaultSchemaName(for databaseType: DatabaseType) -> String {
guard let plugin = driverPlugin(for: databaseType) else { return "public" }
return Swift.type(of: plugin).defaultSchemaName
}

func paginationStyle(for databaseType: DatabaseType) -> SQLDialectDescriptor.PaginationStyle {
sqlDialect(for: databaseType)?.paginationStyle ?? .limit
}

func offsetFetchOrderBy(for databaseType: DatabaseType) -> String {
sqlDialect(for: databaseType)?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)"
}

func databaseGroupingStrategy(for databaseType: DatabaseType) -> GroupingStrategy {
guard let plugin = driverPlugin(for: databaseType) else { return .byDatabase }
return Swift.type(of: plugin).databaseGroupingStrategy
}

func defaultGroupName(for databaseType: DatabaseType) -> String {
guard let plugin = driverPlugin(for: databaseType) else { return "main" }
return Swift.type(of: plugin).defaultGroupName
}

/// All file extensions across all loaded plugins.
var allRegisteredFileExtensions: [String: DatabaseType] {
loadPendingPlugins()
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Export/ExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ final class ExportService {
var total = 0
var failedCount = 0

if databaseType == .mongodb || databaseType == .redis {
if PluginManager.shared.editorLanguage(for: databaseType) != .sql {
for table in tables {
do {
if let count = try await driver.fetchApproximateRowCount(table: table.name) {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Infrastructure/SafeModeGuard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal final class SafeModeGuard {
databaseType: DatabaseType? = nil
) async -> Permission {
let effectiveIsWrite: Bool
if let dbType = databaseType, dbType == .mongodb || dbType == .redis {
if let dbType = databaseType, !PluginManager.shared.supportsReadOnlyMode(for: dbType) {
effectiveIsWrite = true
} else {
effectiveIsWrite = isWriteOperation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ enum SessionStateFactory {
toolbarSt.hasCompletedSetup = true

// Redis: set initial database name eagerly to avoid toolbar flash
if connection.type == .redis {
if connection.type.pluginTypeId == "Redis" {
let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0
toolbarSt.databaseName = String(dbIndex)
}
Expand Down
19 changes: 6 additions & 13 deletions TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,24 @@ internal struct SQLRowToStatementConverter {
self.primaryKeyColumn = primaryKeyColumn
self.databaseType = databaseType
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(for: databaseType)
self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: dialect)
}

private static let maxRows = 50_000

/// Fallback escape function when no plugin driver is available.
/// MySQL/MariaDB/ClickHouse need backslash escaping; others use ANSI SQL.
private static func defaultEscapeFunction(for databaseType: DatabaseType) -> (String) -> String {
switch databaseType {
case .mysql, .mariadb, .clickhouse:
/// Dialects with `requiresBackslashEscaping` get backslash escaping; others use ANSI SQL.
private static func defaultEscapeFunction(dialect: SQLDialectDescriptor?) -> (String) -> String {
if dialect?.requiresBackslashEscaping == true {
return { value in
var result = value
result = result.replacingOccurrences(of: "\\", with: "\\\\")
result = result.replacingOccurrences(of: "'", with: "''")
result = result.replacingOccurrences(of: "\0", with: "\\0")
return result
}
default:
return SQLEscaping.escapeStringLiteral
}
return SQLEscaping.escapeStringLiteral
}

internal func generateInserts(rows: [[String?]]) -> String {
Expand Down Expand Up @@ -109,12 +107,7 @@ internal struct SQLRowToStatementConverter {
whereClause = whereParts.joined(separator: " AND ")
}

switch databaseType {
case .clickhouse:
return "ALTER TABLE \(quotedTable) UPDATE \(setClause) WHERE \(whereClause);"
default:
return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);"
}
return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);"
}

private func formatValue(_ value: String?) -> String {
Expand Down
13 changes: 5 additions & 8 deletions TablePro/Models/Connection/ConnectionToolbarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import AppKit
import Observation
import SwiftUI
import TableProPluginKit

// MARK: - Connection Environment

Expand Down Expand Up @@ -233,15 +234,11 @@ final class ConnectionToolbarState {
/// Update state from a DatabaseConnection model
func update(from connection: DatabaseConnection) {
connectionName = connection.name
if connection.type == .sqlite {
if PluginManager.shared.connectionMode(for: connection.type) == .fileBased {
databaseName = (connection.database as NSString).lastPathComponent
} else if connection.type == .postgresql {
if let session = DatabaseManager.shared.session(for: connection.id),
let database = session.currentDatabase {
databaseName = database
} else {
databaseName = connection.database
}
} else if let session = DatabaseManager.shared.session(for: connection.id),
let database = session.currentDatabase {
databaseName = database
} else {
databaseName = connection.database
}
Expand Down
22 changes: 12 additions & 10 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation
import Observation
import TableProPluginKit

/// Type of tab
enum TabType: Equatable, Codable, Hashable {
Expand Down Expand Up @@ -439,20 +440,21 @@ struct QueryTab: Identifiable, Equatable {
) -> String {
let quote = quoteIdentifier ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: databaseType))
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
if databaseType == .mongodb {
switch PluginManager.shared.editorLanguage(for: databaseType) {
case .javascript:
let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
return "db[\"\(escaped)\"].find({}).limit(\(pageSize))"
} else if databaseType == .redis {
case .bash:
return "SCAN 0 MATCH * COUNT \(pageSize)"
} else if databaseType == .mssql {
default:
let quotedName = quote(tableName)
return "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
} else if databaseType == .oracle {
let quotedName = quote(tableName)
return "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
} else {
let quotedName = quote(tableName)
return "SELECT * FROM \(quotedName) LIMIT \(pageSize);"
switch PluginManager.shared.paginationStyle(for: databaseType) {
case .offsetFetch:
let orderBy = PluginManager.shared.offsetFetchOrderBy(for: databaseType)
return "SELECT * FROM \(quotedName) \(orderBy) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
case .limit:
return "SELECT * FROM \(quotedName) LIMIT \(pageSize);"
}
}
}

Expand Down
7 changes: 3 additions & 4 deletions TablePro/Views/Components/HighlightedSQLTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import AppKit
import SwiftUI
import TableProPluginKit

/// Read-only text view that applies SQL/MQL syntax highlighting via regex
struct HighlightedSQLTextView: NSViewRepresentable {
Expand Down Expand Up @@ -172,11 +173,9 @@ struct HighlightedSQLTextView: NSViewRepresentable {

// Apply pre-compiled patterns
let activePatterns: [(regex: NSRegularExpression, color: NSColor)]
switch databaseType {
case .mongodb:
switch PluginManager.shared.editorLanguage(for: databaseType) {
case .javascript:
activePatterns = Self.mqlPatterns
case .redis:
activePatterns = Self.syntaxPatterns
default:
activePatterns = Self.syntaxPatterns
}
Expand Down
7 changes: 4 additions & 3 deletions TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import AppKit
import SwiftUI
import TableProPluginKit

struct DatabaseSwitcherSheet: View {
@Binding var isPresented: Bool
Expand Down Expand Up @@ -62,7 +63,7 @@ struct DatabaseSwitcherSheet: View {
.padding(.vertical, 12)

// Databases / Schemas toggle (PostgreSQL only)
if databaseType == .postgresql {
if PluginManager.shared.supportsSchemaSwitching(for: databaseType) {
Picker("", selection: $viewModel.mode) {
Text(String(localized: "Databases"))
.tag(DatabaseSwitcherViewModel.Mode.database)
Expand Down Expand Up @@ -90,7 +91,7 @@ struct DatabaseSwitcherSheet: View {
loadingView
} else if let error = viewModel.errorMessage {
errorView(error)
} else if databaseType == .sqlite {
} else if PluginManager.shared.connectionMode(for: databaseType) == .fileBased {
sqliteEmptyState
} else if viewModel.filteredDatabases.isEmpty {
emptyState
Expand Down Expand Up @@ -434,7 +435,7 @@ struct DatabaseSwitcherSheet: View {
viewModel.trackAccess(database: database)

// Call appropriate callback
if viewModel.isSchemaMode, databaseType == .postgresql, let onSelectSchema {
if viewModel.isSchemaMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType), let onSelectSchema {
onSelectSchema(database)
} else {
onSelect(database)
Expand Down
Loading
Loading