Skip to content

Commit 96375df

Browse files
committed
fix: address PR review — fix partial registration, wire up dead code, rewrite tests
1 parent 34eb487 commit 96375df

File tree

2 files changed

+181
-82
lines changed

2 files changed

+181
-82
lines changed

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ final class PluginManager {
6262
private static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager")
6363

6464
private var pendingPluginURLs: [(url: URL, source: PluginSource)] = []
65+
private var validatedConnectionFieldPlugins: Set<String> = []
6566

6667
private init() {}
6768

@@ -242,8 +243,8 @@ final class PluginManager {
242243
// MARK: - Capability Registration
243244

244245
private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) {
245-
pluginInstances[pluginId] = instance
246246
let declared = Set(type(of: instance).capabilities)
247+
var registeredAny = false
247248

248249
if let driver = instance as? any DriverPlugin {
249250
if !declared.contains(.databaseDriver) {
@@ -252,15 +253,17 @@ final class PluginManager {
252253
do {
253254
try validateDriverDescriptor(type(of: driver), pluginId: pluginId)
254255
} catch {
255-
Self.logger.error("Plugin '\(pluginId)' rejected: \(error.localizedDescription)")
256-
return
256+
Self.logger.error("Plugin '\(pluginId)' driver rejected: \(error.localizedDescription)")
257257
}
258-
let typeId = type(of: driver).databaseTypeId
259-
driverPlugins[typeId] = driver
260-
for additionalId in type(of: driver).additionalDatabaseTypeIds {
261-
driverPlugins[additionalId] = driver
258+
if !driverPlugins.keys.contains(type(of: driver).databaseTypeId) {
259+
let typeId = type(of: driver).databaseTypeId
260+
driverPlugins[typeId] = driver
261+
for additionalId in type(of: driver).additionalDatabaseTypeIds {
262+
driverPlugins[additionalId] = driver
263+
}
264+
Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
265+
registeredAny = true
262266
}
263-
Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
264267
}
265268

266269
if let exportPlugin = instance as? any ExportFormatPlugin {
@@ -270,6 +273,7 @@ final class PluginManager {
270273
let formatId = type(of: exportPlugin).formatId
271274
exportPlugins[formatId] = exportPlugin
272275
Self.logger.debug("Registered export plugin '\(pluginId)' for format '\(formatId)'")
276+
registeredAny = true
273277
}
274278

275279
if let importPlugin = instance as? any ImportFormatPlugin {
@@ -279,6 +283,11 @@ final class PluginManager {
279283
let formatId = type(of: importPlugin).formatId
280284
importPlugins[formatId] = importPlugin
281285
Self.logger.debug("Registered import plugin '\(pluginId)' for format '\(formatId)'")
286+
registeredAny = true
287+
}
288+
289+
if registeredAny {
290+
pluginInstances[pluginId] = instance
282291
}
283292
}
284293

@@ -303,7 +312,7 @@ final class PluginManager {
303312

304313
/// Reject-level validation: runs synchronously before registration.
305314
/// Checks only properties already accessed during the loading flow.
306-
private func validateDriverDescriptor(_ driverType: any DriverPlugin.Type, pluginId: String) throws {
315+
func validateDriverDescriptor(_ driverType: any DriverPlugin.Type, pluginId: String) throws {
307316
guard !driverType.databaseTypeId.trimmingCharacters(in: .whitespaces).isEmpty else {
308317
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseTypeId is empty")
309318
}
@@ -322,6 +331,9 @@ final class PluginManager {
322331
}
323332

324333
let allAdditionalIds = driverType.additionalDatabaseTypeIds
334+
// Warn-only (not reject): redundant but harmless — the primary ID is already registered,
335+
// so the duplicate entry in additionalIds just overwrites with the same value.
336+
// Cross-plugin duplicates are rejected above because they indicate a real conflict.
325337
if allAdditionalIds.contains(typeId) {
326338
Self.logger.warning("Plugin '\(pluginId)': additionalDatabaseTypeIds contains the primary databaseTypeId '\(typeId)'")
327339
}
@@ -337,9 +349,10 @@ final class PluginManager {
337349
}
338350
}
339351

340-
/// Warn-level connection field validation. Called lazily when fields are accessed,
341-
/// not during plugin loading (protocol witness tables may be unstable for dynamically loaded bundles).
342-
private func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) {
352+
/// Warn-level connection field validation. Called lazily on first access via
353+
/// `additionalConnectionFields(for:)`, not during plugin loading (protocol witness
354+
/// tables may be unstable for dynamically loaded bundles during the loading path).
355+
func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) {
343356
var seenIds = Set<String>()
344357
for field in fields {
345358
if field.id.trimmingCharacters(in: .whitespaces).isEmpty {
@@ -445,7 +458,13 @@ final class PluginManager {
445458
func additionalConnectionFields(for databaseType: DatabaseType) -> [ConnectionField] {
446459
loadPendingPlugins()
447460
guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return [] }
448-
return Swift.type(of: plugin).additionalConnectionFields
461+
let fields = Swift.type(of: plugin).additionalConnectionFields
462+
let pluginId = databaseType.pluginTypeId
463+
if !validatedConnectionFieldPlugins.contains(pluginId) {
464+
validatedConnectionFieldPlugins.insert(pluginId)
465+
validateConnectionFields(fields, pluginId: pluginId)
466+
}
467+
return fields
449468
}
450469

451470
// MARK: - Plugin Property Lookups

TableProTests/Core/Plugins/PluginValidationTests.swift

Lines changed: 149 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,115 @@ import TableProPluginKit
88
import Testing
99
@testable import TablePro
1010

11+
// MARK: - Mock DriverPlugin for Testing
12+
13+
private final class MockDriverPlugin: NSObject, TableProPlugin, DriverPlugin {
14+
static var pluginName = "MockDriver"
15+
static var pluginVersion = "1.0.0"
16+
static var pluginDescription = "Test plugin"
17+
static var capabilities: [PluginCapability] = [.databaseDriver]
18+
static var dependencies: [String] = []
19+
20+
static var databaseTypeId = "mock-db"
21+
static var databaseDisplayName = "Mock Database"
22+
static var iconName = "cylinder.fill"
23+
static var defaultPort = 9999
24+
25+
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
26+
fatalError("Not used in tests")
27+
}
28+
29+
required override init() {
30+
super.init()
31+
}
32+
33+
static func reset(
34+
typeId: String = "mock-db",
35+
displayName: String = "Mock Database",
36+
additionalIds: [String] = []
37+
) {
38+
databaseTypeId = typeId
39+
databaseDisplayName = displayName
40+
additionalDatabaseTypeIds = additionalIds
41+
}
42+
43+
static var additionalDatabaseTypeIds: [String] = []
44+
}
45+
46+
// MARK: - validateDriverDescriptor Tests
47+
48+
@Suite("PluginManager.validateDriverDescriptor")
49+
struct ValidateDriverDescriptorTests {
50+
51+
@Test("rejects empty databaseTypeId")
52+
@MainActor func rejectsEmptyTypeId() {
53+
MockDriverPlugin.reset(typeId: "")
54+
let pm = PluginManager.shared
55+
#expect(throws: PluginError.self) {
56+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
57+
}
58+
}
59+
60+
@Test("rejects whitespace-only databaseTypeId")
61+
@MainActor func rejectsWhitespaceTypeId() {
62+
MockDriverPlugin.reset(typeId: " ")
63+
let pm = PluginManager.shared
64+
#expect(throws: PluginError.self) {
65+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
66+
}
67+
}
68+
69+
@Test("rejects empty databaseDisplayName")
70+
@MainActor func rejectsEmptyDisplayName() {
71+
MockDriverPlugin.reset(typeId: "valid-id", displayName: "")
72+
let pm = PluginManager.shared
73+
#expect(throws: PluginError.self) {
74+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
75+
}
76+
}
77+
78+
@Test("rejects whitespace-only databaseDisplayName")
79+
@MainActor func rejectsWhitespaceDisplayName() {
80+
MockDriverPlugin.reset(typeId: "valid-id", displayName: " ")
81+
let pm = PluginManager.shared
82+
#expect(throws: PluginError.self) {
83+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
84+
}
85+
}
86+
87+
@Test("accepts valid descriptor with no conflicts")
88+
@MainActor func acceptsValidDescriptor() throws {
89+
MockDriverPlugin.reset(typeId: "unique-test-db-type", displayName: "Unique Test DB")
90+
let pm = PluginManager.shared
91+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
92+
}
93+
94+
@Test("rejects duplicate primary type ID already registered")
95+
@MainActor func rejectsDuplicatePrimaryTypeId() {
96+
// "MySQL" is registered by the built-in MySQL plugin
97+
MockDriverPlugin.reset(typeId: "MySQL", displayName: "Fake MySQL")
98+
let pm = PluginManager.shared
99+
#expect(throws: PluginError.self) {
100+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
101+
}
102+
}
103+
104+
@Test("rejects duplicate additional type ID already registered")
105+
@MainActor func rejectsDuplicateAdditionalTypeId() {
106+
MockDriverPlugin.reset(
107+
typeId: "unique-test-db-type-2",
108+
displayName: "Test DB",
109+
additionalIds: ["MySQL"]
110+
)
111+
let pm = PluginManager.shared
112+
#expect(throws: PluginError.self) {
113+
try pm.validateDriverDescriptor(MockDriverPlugin.self, pluginId: "test")
114+
}
115+
}
116+
}
117+
118+
// MARK: - PluginError.invalidDescriptor Formatting
119+
11120
@Suite("PluginError.invalidDescriptor")
12121
struct PluginErrorInvalidDescriptorTests {
13122

@@ -33,90 +142,61 @@ struct PluginErrorInvalidDescriptorTests {
33142
#expect(description.contains("mysql"))
34143
#expect(description.contains("MySQL"))
35144
}
36-
37-
@Test("error description for empty display name")
38-
func emptyDisplayNameDescription() {
39-
let error = PluginError.invalidDescriptor(
40-
pluginId: "com.example.test",
41-
reason: "databaseDisplayName is empty"
42-
)
43-
let description = error.localizedDescription
44-
#expect(description.contains("databaseDisplayName is empty"))
45-
}
46-
47-
@Test("error description for additional type ID conflict")
48-
func additionalTypeIdConflict() {
49-
let error = PluginError.invalidDescriptor(
50-
pluginId: "com.example.multi",
51-
reason: "additionalDatabaseTypeId 'redshift' is already registered by 'PostgreSQL'"
52-
)
53-
let description = error.localizedDescription
54-
#expect(description.contains("additionalDatabaseTypeId"))
55-
#expect(description.contains("redshift"))
56-
#expect(description.contains("PostgreSQL"))
57-
}
58145
}
59146

60-
@Suite("ConnectionField Validation Logic")
61-
struct ConnectionFieldValidationLogicTests {
147+
// MARK: - validateConnectionFields Tests
148+
149+
@Suite("PluginManager.validateConnectionFields")
150+
struct ValidateConnectionFieldsTests {
62151

63-
@Test("duplicate field IDs are detectable")
64-
func duplicateFieldIds() {
152+
@Test("duplicate field IDs are detected")
153+
@MainActor func duplicateFieldIds() {
65154
let fields = [
66155
ConnectionField(id: "encoding", label: "Encoding"),
67156
ConnectionField(id: "timeout", label: "Timeout"),
68157
ConnectionField(id: "encoding", label: "Character Encoding")
69158
]
70-
var seenIds = Set<String>()
71-
var duplicates: [String] = []
72-
for field in fields {
73-
if !seenIds.insert(field.id).inserted {
74-
duplicates.append(field.id)
75-
}
76-
}
77-
#expect(duplicates == ["encoding"])
159+
// Should not crash — warns via logger
160+
PluginManager.shared.validateConnectionFields(fields, pluginId: "test")
78161
}
79162

80-
@Test("empty field ID is detectable")
81-
func emptyFieldId() {
82-
let field = ConnectionField(id: "", label: "Something")
83-
#expect(field.id.trimmingCharacters(in: .whitespaces).isEmpty)
163+
@Test("empty field ID is detected without crash")
164+
@MainActor func emptyFieldId() {
165+
let fields = [ConnectionField(id: "", label: "Something")]
166+
PluginManager.shared.validateConnectionFields(fields, pluginId: "test")
84167
}
85168

86-
@Test("empty field label is detectable")
87-
func emptyFieldLabel() {
88-
let field = ConnectionField(id: "test", label: "")
89-
#expect(field.label.trimmingCharacters(in: .whitespaces).isEmpty)
169+
@Test("empty field label is detected without crash")
170+
@MainActor func emptyFieldLabel() {
171+
let fields = [ConnectionField(id: "test", label: "")]
172+
PluginManager.shared.validateConnectionFields(fields, pluginId: "test")
90173
}
91174

92-
@Test("dropdown with empty options is detectable")
93-
func emptyDropdownOptions() {
94-
let field = ConnectionField(
95-
id: "encoding",
96-
label: "Encoding",
97-
fieldType: .dropdown(options: [])
98-
)
99-
if case .dropdown(let options) = field.fieldType {
100-
#expect(options.isEmpty)
101-
} else {
102-
Issue.record("Expected dropdown field type")
103-
}
175+
@Test("dropdown with empty options is detected without crash")
176+
@MainActor func emptyDropdownOptions() {
177+
let fields = [
178+
ConnectionField(
179+
id: "encoding",
180+
label: "Encoding",
181+
fieldType: .dropdown(options: [])
182+
)
183+
]
184+
PluginManager.shared.validateConnectionFields(fields, pluginId: "test")
104185
}
105186

106-
@Test("dropdown with options is valid")
107-
func validDropdown() {
108-
let field = ConnectionField(
109-
id: "encoding",
110-
label: "Encoding",
111-
fieldType: .dropdown(options: [
112-
ConnectionField.DropdownOption(value: "utf8", label: "UTF-8"),
113-
ConnectionField.DropdownOption(value: "latin1", label: "Latin-1")
114-
])
115-
)
116-
if case .dropdown(let options) = field.fieldType {
117-
#expect(options.count == 2)
118-
} else {
119-
Issue.record("Expected dropdown field type")
120-
}
187+
@Test("valid fields pass without issue")
188+
@MainActor func validFields() {
189+
let fields = [
190+
ConnectionField(id: "encoding", label: "Encoding"),
191+
ConnectionField(
192+
id: "mode",
193+
label: "Mode",
194+
fieldType: .dropdown(options: [
195+
ConnectionField.DropdownOption(value: "fast", label: "Fast"),
196+
ConnectionField.DropdownOption(value: "safe", label: "Safe")
197+
])
198+
)
199+
]
200+
PluginManager.shared.validateConnectionFields(fields, pluginId: "test")
121201
}
122202
}

0 commit comments

Comments
 (0)