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
19 changes: 14 additions & 5 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,24 @@ final class PluginManager {
Self.logger.error("Plugin '\(pluginId)' driver rejected: \(error.localizedDescription)")
}
if !driverPlugins.keys.contains(type(of: driver).databaseTypeId) {
let typeId = type(of: driver).databaseTypeId
let driverType = type(of: driver)
let typeId = driverType.databaseTypeId
driverPlugins[typeId] = driver
for additionalId in type(of: driver).additionalDatabaseTypeIds {
for additionalId in driverType.additionalDatabaseTypeIds {
driverPlugins[additionalId] = driver
}

// Built-in defaults are pre-populated in PluginMetadataRegistry.init().
// Runtime-loaded plugins may be compiled against an older TableProPluginKit,
// so we don't read new protocol properties from them to avoid witness table crashes.
// Self-register plugin metadata from the DriverPlugin protocol.
// parameterStyle defaults to .questionMark; built-in defaults already have correct values.
let snapshot = PluginMetadataRegistry.shared.buildMetadataSnapshot(
from: driverType,
isDownloadable: driverType.isDownloadable
)
PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId)
for additionalId in driverType.additionalDatabaseTypeIds {
PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: additionalId)
PluginMetadataRegistry.shared.registerTypeAlias(additionalId, primaryTypeId: typeId)
}

Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
registeredAny = true
Expand Down
107 changes: 107 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
private let lock = NSLock()
private var snapshots: [String: PluginMetadataSnapshot] = [:]
private var schemeIndex: [String: String] = [:]
private var reverseTypeIndex: [String: String] = [:]

private init() {
registerBuiltInDefaults()
Expand Down Expand Up @@ -505,6 +506,11 @@ final class PluginMetadataRegistry: @unchecked Sendable {
schemeIndex[scheme.lowercased()] = entry.typeId
}
}

// Built-in type aliases: multi-type plugins where an alias maps to a primary plugin type ID
reverseTypeIndex["MariaDB"] = "MySQL"
reverseTypeIndex["Redshift"] = "PostgreSQL"
reverseTypeIndex["ScyllaDB"] = "Cassandra"
}

func register(snapshot: PluginMetadataSnapshot, forTypeId typeId: String) {
Expand Down Expand Up @@ -543,6 +549,107 @@ final class PluginMetadataRegistry: @unchecked Sendable {
return DatabaseType(rawValue: typeId)
}

// MARK: - Dynamic Type Registration

/// Registers an alias type ID that maps to a primary type ID.
/// Used for multi-type plugins (e.g., MariaDB → MySQL, Redshift → PostgreSQL).
func registerTypeAlias(_ aliasTypeId: String, primaryTypeId: String) {
lock.lock()
defer { lock.unlock() }
reverseTypeIndex[aliasTypeId] = primaryTypeId
}

/// Returns all registered type IDs (sorted for deterministic UI ordering).
func allRegisteredTypeIds() -> [String] {
lock.lock()
defer { lock.unlock() }
return Array(snapshots.keys).sorted()
}

/// Resolves a database type raw value to its plugin type ID for driver lookup.
/// For multi-type plugins (MySQL serves MariaDB), maps the alias to the primary.
/// Does NOT remap for snapshot lookups — use snapshot(forTypeId:) directly.
func pluginTypeId(for rawValue: String) -> String {
lock.lock()
defer { lock.unlock() }
return reverseTypeIndex[rawValue] ?? rawValue
}

/// Checks if a type ID is registered (has a snapshot).
func hasType(_ typeId: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return snapshots[typeId] != nil
}

// MARK: - Snapshot Builder

/// Builds a PluginMetadataSnapshot from a DriverPlugin's protocol properties.
/// Used by PluginManager to self-register plugins at load time.
func buildMetadataSnapshot(
from driverType: any DriverPlugin.Type,
isDownloadable: Bool = false,
parameterStyle: ParameterStyle = .questionMark
) -> PluginMetadataSnapshot {
let schemes = driverType.urlSchemes
let primaryScheme = schemes.first ?? driverType.databaseTypeId.lowercased()

return PluginMetadataSnapshot(
displayName: driverType.databaseDisplayName,
iconName: driverType.iconName,
defaultPort: driverType.defaultPort,
requiresAuthentication: driverType.requiresAuthentication,
supportsForeignKeys: driverType.supportsForeignKeys,
supportsSchemaEditing: driverType.supportsSchemaEditing,
isDownloadable: isDownloadable,
primaryUrlScheme: primaryScheme,
parameterStyle: parameterStyle,
navigationModel: driverType.navigationModel,
explainVariants: driverType.explainVariants,
pathFieldRole: driverType.pathFieldRole,
supportsHealthMonitor: driverType.supportsHealthMonitor,
urlSchemes: schemes,
postConnectActions: driverType.postConnectActions,
brandColorHex: driverType.brandColorHex,
queryLanguageName: driverType.queryLanguageName,
editorLanguage: driverType.editorLanguage,
connectionMode: driverType.connectionMode,
supportsDatabaseSwitching: driverType.supportsDatabaseSwitching,
capabilities: PluginMetadataSnapshot.CapabilityFlags(
supportsSchemaSwitching: driverType.supportsSchemaSwitching,
supportsImport: driverType.supportsImport,
supportsExport: driverType.supportsExport,
supportsSSH: driverType.supportsSSH,
supportsSSL: driverType.supportsSSL,
supportsCascadeDrop: driverType.supportsCascadeDrop,
supportsForeignKeyDisable: driverType.supportsForeignKeyDisable,
supportsReadOnlyMode: driverType.supportsReadOnlyMode,
supportsQueryProgress: driverType.supportsQueryProgress,
requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: driverType.defaultSchemaName,
defaultGroupName: driverType.defaultGroupName,
tableEntityName: driverType.tableEntityName,
defaultPrimaryKeyColumn: driverType.defaultPrimaryKeyColumn,
immutableColumns: driverType.immutableColumns,
systemDatabaseNames: driverType.systemDatabaseNames,
systemSchemaNames: driverType.systemSchemaNames,
fileExtensions: driverType.fileExtensions,
databaseGroupingStrategy: driverType.databaseGroupingStrategy,
structureColumnFields: driverType.structureColumnFields
),
editor: PluginMetadataSnapshot.EditorConfig(
sqlDialect: driverType.sqlDialect,
statementCompletions: driverType.statementCompletions,
columnTypesByCategory: driverType.columnTypesByCategory
),
connection: PluginMetadataSnapshot.ConnectionConfig(
additionalConnectionFields: driverType.additionalConnectionFields
)
)
}

func allFileExtensions() -> [String: String] {
lock.lock()
defer { lock.unlock() }
Expand Down
96 changes: 96 additions & 0 deletions TablePro/Core/Plugins/Registry/RegistryModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct RegistryPlugin: Codable, Sendable, Identifiable {
let minPluginKitVersion: Int?
let iconName: String?
let isVerified: Bool
let metadata: RegistryPluginMetadata?
}

extension RegistryPlugin {
Expand Down Expand Up @@ -83,3 +84,98 @@ enum RegistryCategory: String, Codable, Sendable, CaseIterable, Identifiable {
}
}
}

// MARK: - Plugin Metadata (self-describing registry plugins)

struct RegistryPluginMetadata: Codable, Sendable {
let displayName: String?
let iconName: String?
let defaultPort: Int?
let brandColorHex: String?
let connectionMode: String?
let editorLanguage: String?
let queryLanguageName: String?
let primaryUrlScheme: String?
let parameterStyle: String?

let requiresAuthentication: Bool?
let supportsForeignKeys: Bool?
let supportsSchemaEditing: Bool?
let supportsDatabaseSwitching: Bool?
let supportsSchemaSwitching: Bool?
let supportsSSH: Bool?
let supportsSSL: Bool?
let supportsImport: Bool?
let supportsExport: Bool?
let supportsHealthMonitor: Bool?
let supportsCascadeDrop: Bool?
let supportsForeignKeyDisable: Bool?
let supportsReadOnlyMode: Bool?
let supportsQueryProgress: Bool?
let requiresReconnectForDatabaseSwitch: Bool?

let urlSchemes: [String]?
let fileExtensions: [String]?
let systemDatabaseNames: [String]?
let systemSchemaNames: [String]?
let defaultSchemaName: String?
let defaultGroupName: String?
let tableEntityName: String?
let defaultPrimaryKeyColumn: String?
let immutableColumns: [String]?

let navigationModel: String?
let pathFieldRole: String?
let databaseGroupingStrategy: String?
let structureColumnFields: [String]?
let postConnectActions: [RegistryPostConnectAction]?
let additionalConnectionFields: [RegistryConnectionField]?
let explainVariants: [RegistryExplainVariant]?
let sqlDialect: RegistrySqlDialect?
let statementCompletions: [RegistryCompletionEntry]?
let columnTypesByCategory: [String: [String]]?
}

struct RegistryConnectionField: Codable, Sendable {
let id: String
let label: String
let placeholder: String?
let defaultValue: String?
let fieldType: String?
let section: String?
let options: [RegistryDropdownOption]?
}

struct RegistryDropdownOption: Codable, Sendable {
let value: String
let label: String
}

struct RegistryPostConnectAction: Codable, Sendable {
let type: String
let fieldId: String?
}

struct RegistryExplainVariant: Codable, Sendable {
let name: String
let prefix: String
}

struct RegistrySqlDialect: Codable, Sendable {
let identifierQuote: String?
let keywords: [String]?
let functions: [String]?
let dataTypes: [String]?
let tableOptions: [String]?
let regexSyntax: String?
let booleanLiteralStyle: String?
let likeEscapeStyle: String?
let paginationStyle: String?
let offsetFetchOrderBy: String?
let requiresBackslashEscaping: Bool?
}

struct RegistryCompletionEntry: Codable, Sendable {
let label: String
let insertText: String
}
6 changes: 3 additions & 3 deletions TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ enum DeeplinkHandler {
let host = value("host"), !host.isEmpty,
let typeStr = value("type"),
let dbType = DatabaseType(validating: typeStr)
?? DatabaseType.allKnownTypes.first(where: {
$0.rawValue.lowercased() == typeStr.lowercased()
})
?? PluginMetadataRegistry.shared.allRegisteredTypeIds()
.first(where: { $0.lowercased() == typeStr.lowercased() })
.map({ DatabaseType(rawValue: $0) })
else {
logger.warning("Import deep link missing required params")
return nil
Expand Down
35 changes: 11 additions & 24 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,14 @@ struct DatabaseType: Hashable, Identifiable, Sendable {
}

extension DatabaseType {
// Built-in types (bundled plugins)
static let mysql = DatabaseType(rawValue: "MySQL")
static let mariadb = DatabaseType(rawValue: "MariaDB")
static let postgresql = DatabaseType(rawValue: "PostgreSQL")
static let sqlite = DatabaseType(rawValue: "SQLite")
static let redshift = DatabaseType(rawValue: "Redshift")

// Registry-distributed types (known plugins, downloadable separately)
static let mongodb = DatabaseType(rawValue: "MongoDB")
static let redis = DatabaseType(rawValue: "Redis")
static let mssql = DatabaseType(rawValue: "SQL Server")
Expand All @@ -248,29 +251,27 @@ extension DatabaseType: Codable {
}

extension DatabaseType {
/// All built-in database types.
static let allKnownTypes: [DatabaseType] = [
.mysql, .mariadb, .postgresql, .sqlite, .redshift,
.mongodb, .redis, .mssql, .oracle, .clickhouse, .duckdb,
.cassandra, .scylladb, .etcd,
]
/// All registered database types, derived dynamically from the plugin metadata registry.
static var allKnownTypes: [DatabaseType] {
PluginMetadataRegistry.shared.allRegisteredTypeIds().map { DatabaseType(rawValue: $0) }
}

/// Compatibility shim for CaseIterable call sites.
static var allCases: [DatabaseType] { allKnownTypes }
}

extension DatabaseType {
/// Returns nil if rawValue doesn't match any known type.
/// Returns nil if rawValue doesn't match any registered type.
init?(validating rawValue: String) {
guard Self.allKnownTypes.contains(where: { $0.rawValue == rawValue }) else { return nil }
guard PluginMetadataRegistry.shared.hasType(rawValue) else { return nil }
self.rawValue = rawValue
}
}

extension DatabaseType {
/// Plugin type ID used for PluginManager lookup.
/// Plugin type ID used for PluginManager lookup, resolved via the registry.
var pluginTypeId: String {
Self.pluginTypeIdMap[self] ?? rawValue
PluginMetadataRegistry.shared.pluginTypeId(for: rawValue)
}

var isDownloadablePlugin: Bool {
Expand All @@ -296,20 +297,6 @@ extension DatabaseType {
var supportsSchemaEditing: Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsSchemaEditing ?? true
}

private static let pluginTypeIdMap: [DatabaseType: String] = [
.mysql: "MySQL", .mariadb: "MySQL",
.postgresql: "PostgreSQL", .redshift: "PostgreSQL",
.mssql: "SQL Server",
.sqlite: "SQLite",
.mongodb: "MongoDB",
.redis: "Redis",
.oracle: "Oracle",
.clickhouse: "ClickHouse",
.duckdb: "DuckDB",
.cassandra: "Cassandra", .scylladb: "Cassandra",
.etcd: "etcd",
]
}

// MARK: - Connection Color
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Editor/HistoryPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ private extension HistoryPanelView {
HighlightedSQLTextView(
sql: entry.query.hasSuffix(";") ? entry.query : entry.query + ";",
databaseType: entry.query.trimmingCharacters(in: .whitespaces)
.hasPrefix("db.") ? .mongodb : .mysql // Redis commands use SQL patterns for highlighting
.hasPrefix("db.") ? .mongodb : .mysql
)
.background(Color(nsColor: ThemeEngine.shared.colors.editor.background))

Expand Down
5 changes: 4 additions & 1 deletion TableProTests/Helpers/TestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import Testing
enum TestFixtures {
// MARK: - Database Types

static let allDatabaseTypes: [DatabaseType] = [.mysql, .mariadb, .postgresql, .sqlite, .redshift, .mongodb, .redis, .clickhouse]
static let allDatabaseTypes: [DatabaseType] = [
.mysql, .mariadb, .postgresql, .sqlite, .redshift,
.mongodb, .redis, .clickhouse
]

// MARK: - ClickHouse Connection Fixture

Expand Down
Loading
Loading