Skip to content

Commit 8662bad

Browse files
authored
Merge pull request #346 from datlechin/refactor/self-describing-plugins
refactor: self-describing plugin system — zero core changes for new drivers
2 parents 7b04fb4 + fc55067 commit 8662bad

11 files changed

Lines changed: 273 additions & 67 deletions

File tree

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,24 @@ final class PluginManager {
251251
Self.logger.error("Plugin '\(pluginId)' driver rejected: \(error.localizedDescription)")
252252
}
253253
if !driverPlugins.keys.contains(type(of: driver).databaseTypeId) {
254-
let typeId = type(of: driver).databaseTypeId
254+
let driverType = type(of: driver)
255+
let typeId = driverType.databaseTypeId
255256
driverPlugins[typeId] = driver
256-
for additionalId in type(of: driver).additionalDatabaseTypeIds {
257+
for additionalId in driverType.additionalDatabaseTypeIds {
257258
driverPlugins[additionalId] = driver
258259
}
259260

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

264273
Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
265274
registeredAny = true

TablePro/Core/Plugins/PluginMetadataRegistry.swift

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
125125
private let lock = NSLock()
126126
private var snapshots: [String: PluginMetadataSnapshot] = [:]
127127
private var schemeIndex: [String: String] = [:]
128+
private var reverseTypeIndex: [String: String] = [:]
128129

129130
private init() {
130131
registerBuiltInDefaults()
@@ -505,6 +506,11 @@ final class PluginMetadataRegistry: @unchecked Sendable {
505506
schemeIndex[scheme.lowercased()] = entry.typeId
506507
}
507508
}
509+
510+
// Built-in type aliases: multi-type plugins where an alias maps to a primary plugin type ID
511+
reverseTypeIndex["MariaDB"] = "MySQL"
512+
reverseTypeIndex["Redshift"] = "PostgreSQL"
513+
reverseTypeIndex["ScyllaDB"] = "Cassandra"
508514
}
509515

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

552+
// MARK: - Dynamic Type Registration
553+
554+
/// Registers an alias type ID that maps to a primary type ID.
555+
/// Used for multi-type plugins (e.g., MariaDB → MySQL, Redshift → PostgreSQL).
556+
func registerTypeAlias(_ aliasTypeId: String, primaryTypeId: String) {
557+
lock.lock()
558+
defer { lock.unlock() }
559+
reverseTypeIndex[aliasTypeId] = primaryTypeId
560+
}
561+
562+
/// Returns all registered type IDs (sorted for deterministic UI ordering).
563+
func allRegisteredTypeIds() -> [String] {
564+
lock.lock()
565+
defer { lock.unlock() }
566+
return Array(snapshots.keys).sorted()
567+
}
568+
569+
/// Resolves a database type raw value to its plugin type ID for driver lookup.
570+
/// For multi-type plugins (MySQL serves MariaDB), maps the alias to the primary.
571+
/// Does NOT remap for snapshot lookups — use snapshot(forTypeId:) directly.
572+
func pluginTypeId(for rawValue: String) -> String {
573+
lock.lock()
574+
defer { lock.unlock() }
575+
return reverseTypeIndex[rawValue] ?? rawValue
576+
}
577+
578+
/// Checks if a type ID is registered (has a snapshot).
579+
func hasType(_ typeId: String) -> Bool {
580+
lock.lock()
581+
defer { lock.unlock() }
582+
return snapshots[typeId] != nil
583+
}
584+
585+
// MARK: - Snapshot Builder
586+
587+
/// Builds a PluginMetadataSnapshot from a DriverPlugin's protocol properties.
588+
/// Used by PluginManager to self-register plugins at load time.
589+
func buildMetadataSnapshot(
590+
from driverType: any DriverPlugin.Type,
591+
isDownloadable: Bool = false,
592+
parameterStyle: ParameterStyle = .questionMark
593+
) -> PluginMetadataSnapshot {
594+
let schemes = driverType.urlSchemes
595+
let primaryScheme = schemes.first ?? driverType.databaseTypeId.lowercased()
596+
597+
return PluginMetadataSnapshot(
598+
displayName: driverType.databaseDisplayName,
599+
iconName: driverType.iconName,
600+
defaultPort: driverType.defaultPort,
601+
requiresAuthentication: driverType.requiresAuthentication,
602+
supportsForeignKeys: driverType.supportsForeignKeys,
603+
supportsSchemaEditing: driverType.supportsSchemaEditing,
604+
isDownloadable: isDownloadable,
605+
primaryUrlScheme: primaryScheme,
606+
parameterStyle: parameterStyle,
607+
navigationModel: driverType.navigationModel,
608+
explainVariants: driverType.explainVariants,
609+
pathFieldRole: driverType.pathFieldRole,
610+
supportsHealthMonitor: driverType.supportsHealthMonitor,
611+
urlSchemes: schemes,
612+
postConnectActions: driverType.postConnectActions,
613+
brandColorHex: driverType.brandColorHex,
614+
queryLanguageName: driverType.queryLanguageName,
615+
editorLanguage: driverType.editorLanguage,
616+
connectionMode: driverType.connectionMode,
617+
supportsDatabaseSwitching: driverType.supportsDatabaseSwitching,
618+
capabilities: PluginMetadataSnapshot.CapabilityFlags(
619+
supportsSchemaSwitching: driverType.supportsSchemaSwitching,
620+
supportsImport: driverType.supportsImport,
621+
supportsExport: driverType.supportsExport,
622+
supportsSSH: driverType.supportsSSH,
623+
supportsSSL: driverType.supportsSSL,
624+
supportsCascadeDrop: driverType.supportsCascadeDrop,
625+
supportsForeignKeyDisable: driverType.supportsForeignKeyDisable,
626+
supportsReadOnlyMode: driverType.supportsReadOnlyMode,
627+
supportsQueryProgress: driverType.supportsQueryProgress,
628+
requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch
629+
),
630+
schema: PluginMetadataSnapshot.SchemaInfo(
631+
defaultSchemaName: driverType.defaultSchemaName,
632+
defaultGroupName: driverType.defaultGroupName,
633+
tableEntityName: driverType.tableEntityName,
634+
defaultPrimaryKeyColumn: driverType.defaultPrimaryKeyColumn,
635+
immutableColumns: driverType.immutableColumns,
636+
systemDatabaseNames: driverType.systemDatabaseNames,
637+
systemSchemaNames: driverType.systemSchemaNames,
638+
fileExtensions: driverType.fileExtensions,
639+
databaseGroupingStrategy: driverType.databaseGroupingStrategy,
640+
structureColumnFields: driverType.structureColumnFields
641+
),
642+
editor: PluginMetadataSnapshot.EditorConfig(
643+
sqlDialect: driverType.sqlDialect,
644+
statementCompletions: driverType.statementCompletions,
645+
columnTypesByCategory: driverType.columnTypesByCategory
646+
),
647+
connection: PluginMetadataSnapshot.ConnectionConfig(
648+
additionalConnectionFields: driverType.additionalConnectionFields
649+
)
650+
)
651+
}
652+
546653
func allFileExtensions() -> [String: String] {
547654
lock.lock()
548655
defer { lock.unlock() }

TablePro/Core/Plugins/Registry/RegistryModels.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ struct RegistryPlugin: Codable, Sendable, Identifiable {
4545
let minPluginKitVersion: Int?
4646
let iconName: String?
4747
let isVerified: Bool
48+
let metadata: RegistryPluginMetadata?
4849
}
4950

5051
extension RegistryPlugin {
@@ -83,3 +84,98 @@ enum RegistryCategory: String, Codable, Sendable, CaseIterable, Identifiable {
8384
}
8485
}
8586
}
87+
88+
// MARK: - Plugin Metadata (self-describing registry plugins)
89+
90+
struct RegistryPluginMetadata: Codable, Sendable {
91+
let displayName: String?
92+
let iconName: String?
93+
let defaultPort: Int?
94+
let brandColorHex: String?
95+
let connectionMode: String?
96+
let editorLanguage: String?
97+
let queryLanguageName: String?
98+
let primaryUrlScheme: String?
99+
let parameterStyle: String?
100+
101+
let requiresAuthentication: Bool?
102+
let supportsForeignKeys: Bool?
103+
let supportsSchemaEditing: Bool?
104+
let supportsDatabaseSwitching: Bool?
105+
let supportsSchemaSwitching: Bool?
106+
let supportsSSH: Bool?
107+
let supportsSSL: Bool?
108+
let supportsImport: Bool?
109+
let supportsExport: Bool?
110+
let supportsHealthMonitor: Bool?
111+
let supportsCascadeDrop: Bool?
112+
let supportsForeignKeyDisable: Bool?
113+
let supportsReadOnlyMode: Bool?
114+
let supportsQueryProgress: Bool?
115+
let requiresReconnectForDatabaseSwitch: Bool?
116+
117+
let urlSchemes: [String]?
118+
let fileExtensions: [String]?
119+
let systemDatabaseNames: [String]?
120+
let systemSchemaNames: [String]?
121+
let defaultSchemaName: String?
122+
let defaultGroupName: String?
123+
let tableEntityName: String?
124+
let defaultPrimaryKeyColumn: String?
125+
let immutableColumns: [String]?
126+
127+
let navigationModel: String?
128+
let pathFieldRole: String?
129+
let databaseGroupingStrategy: String?
130+
let structureColumnFields: [String]?
131+
let postConnectActions: [RegistryPostConnectAction]?
132+
let additionalConnectionFields: [RegistryConnectionField]?
133+
let explainVariants: [RegistryExplainVariant]?
134+
let sqlDialect: RegistrySqlDialect?
135+
let statementCompletions: [RegistryCompletionEntry]?
136+
let columnTypesByCategory: [String: [String]]?
137+
}
138+
139+
struct RegistryConnectionField: Codable, Sendable {
140+
let id: String
141+
let label: String
142+
let placeholder: String?
143+
let defaultValue: String?
144+
let fieldType: String?
145+
let section: String?
146+
let options: [RegistryDropdownOption]?
147+
}
148+
149+
struct RegistryDropdownOption: Codable, Sendable {
150+
let value: String
151+
let label: String
152+
}
153+
154+
struct RegistryPostConnectAction: Codable, Sendable {
155+
let type: String
156+
let fieldId: String?
157+
}
158+
159+
struct RegistryExplainVariant: Codable, Sendable {
160+
let name: String
161+
let prefix: String
162+
}
163+
164+
struct RegistrySqlDialect: Codable, Sendable {
165+
let identifierQuote: String?
166+
let keywords: [String]?
167+
let functions: [String]?
168+
let dataTypes: [String]?
169+
let tableOptions: [String]?
170+
let regexSyntax: String?
171+
let booleanLiteralStyle: String?
172+
let likeEscapeStyle: String?
173+
let paginationStyle: String?
174+
let offsetFetchOrderBy: String?
175+
let requiresBackslashEscaping: Bool?
176+
}
177+
178+
struct RegistryCompletionEntry: Codable, Sendable {
179+
let label: String
180+
let insertText: String
181+
}

TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ enum DeeplinkHandler {
8888
let host = value("host"), !host.isEmpty,
8989
let typeStr = value("type"),
9090
let dbType = DatabaseType(validating: typeStr)
91-
?? DatabaseType.allKnownTypes.first(where: {
92-
$0.rawValue.lowercased() == typeStr.lowercased()
93-
})
91+
?? PluginMetadataRegistry.shared.allRegisteredTypeIds()
92+
.first(where: { $0.lowercased() == typeStr.lowercased() })
93+
.map({ DatabaseType(rawValue: $0) })
9494
else {
9595
logger.warning("Import deep link missing required params")
9696
return nil

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,14 @@ struct DatabaseType: Hashable, Identifiable, Sendable {
219219
}
220220

221221
extension DatabaseType {
222+
// Built-in types (bundled plugins)
222223
static let mysql = DatabaseType(rawValue: "MySQL")
223224
static let mariadb = DatabaseType(rawValue: "MariaDB")
224225
static let postgresql = DatabaseType(rawValue: "PostgreSQL")
225226
static let sqlite = DatabaseType(rawValue: "SQLite")
226227
static let redshift = DatabaseType(rawValue: "Redshift")
228+
229+
// Registry-distributed types (known plugins, downloadable separately)
227230
static let mongodb = DatabaseType(rawValue: "MongoDB")
228231
static let redis = DatabaseType(rawValue: "Redis")
229232
static let mssql = DatabaseType(rawValue: "SQL Server")
@@ -248,29 +251,27 @@ extension DatabaseType: Codable {
248251
}
249252

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

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

262263
extension DatabaseType {
263-
/// Returns nil if rawValue doesn't match any known type.
264+
/// Returns nil if rawValue doesn't match any registered type.
264265
init?(validating rawValue: String) {
265-
guard Self.allKnownTypes.contains(where: { $0.rawValue == rawValue }) else { return nil }
266+
guard PluginMetadataRegistry.shared.hasType(rawValue) else { return nil }
266267
self.rawValue = rawValue
267268
}
268269
}
269270

270271
extension DatabaseType {
271-
/// Plugin type ID used for PluginManager lookup.
272+
/// Plugin type ID used for PluginManager lookup, resolved via the registry.
272273
var pluginTypeId: String {
273-
Self.pluginTypeIdMap[self] ?? rawValue
274+
PluginMetadataRegistry.shared.pluginTypeId(for: rawValue)
274275
}
275276

276277
var isDownloadablePlugin: Bool {
@@ -296,20 +297,6 @@ extension DatabaseType {
296297
var supportsSchemaEditing: Bool {
297298
PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsSchemaEditing ?? true
298299
}
299-
300-
private static let pluginTypeIdMap: [DatabaseType: String] = [
301-
.mysql: "MySQL", .mariadb: "MySQL",
302-
.postgresql: "PostgreSQL", .redshift: "PostgreSQL",
303-
.mssql: "SQL Server",
304-
.sqlite: "SQLite",
305-
.mongodb: "MongoDB",
306-
.redis: "Redis",
307-
.oracle: "Oracle",
308-
.clickhouse: "ClickHouse",
309-
.duckdb: "DuckDB",
310-
.cassandra: "Cassandra", .scylladb: "Cassandra",
311-
.etcd: "etcd",
312-
]
313300
}
314301

315302
// MARK: - Connection Color

TablePro/Views/Editor/HistoryPanelView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ private extension HistoryPanelView {
223223
HighlightedSQLTextView(
224224
sql: entry.query.hasSuffix(";") ? entry.query : entry.query + ";",
225225
databaseType: entry.query.trimmingCharacters(in: .whitespaces)
226-
.hasPrefix("db.") ? .mongodb : .mysql // Redis commands use SQL patterns for highlighting
226+
.hasPrefix("db.") ? .mongodb : .mysql
227227
)
228228
.background(Color(nsColor: ThemeEngine.shared.colors.editor.background))
229229

TableProTests/Helpers/TestFixtures.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import Testing
1212
enum TestFixtures {
1313
// MARK: - Database Types
1414

15-
static let allDatabaseTypes: [DatabaseType] = [.mysql, .mariadb, .postgresql, .sqlite, .redshift, .mongodb, .redis, .clickhouse]
15+
static let allDatabaseTypes: [DatabaseType] = [
16+
.mysql, .mariadb, .postgresql, .sqlite, .redshift,
17+
.mongodb, .redis, .clickhouse
18+
]
1619

1720
// MARK: - ClickHouse Connection Fixture
1821

0 commit comments

Comments
 (0)