@@ -62,6 +62,8 @@ 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 > = [ ]
66+ private var validatedDialectPlugins : Set < String > = [ ]
6567
6668 private init ( ) { }
6769
@@ -242,19 +244,27 @@ final class PluginManager {
242244 // MARK: - Capability Registration
243245
244246 private func registerCapabilities( _ instance: any TableProPlugin , pluginId: String ) {
245- pluginInstances [ pluginId] = instance
246247 let declared = Set ( type ( of: instance) . capabilities)
248+ var registeredAny = false
247249
248250 if let driver = instance as? any DriverPlugin {
249251 if !declared. contains ( . databaseDriver) {
250252 Self . logger. warning ( " Plugin ' \( pluginId) ' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway " )
251253 }
252- let typeId = type ( of: driver) . databaseTypeId
253- driverPlugins [ typeId] = driver
254- for additionalId in type ( of: driver) . additionalDatabaseTypeIds {
255- driverPlugins [ additionalId] = driver
254+ do {
255+ try validateDriverDescriptor ( type ( of: driver) , pluginId: pluginId)
256+ } catch {
257+ Self . logger. error ( " Plugin ' \( pluginId) ' driver rejected: \( error. localizedDescription) " )
258+ }
259+ if !driverPlugins. keys. contains ( type ( of: driver) . databaseTypeId) {
260+ let typeId = type ( of: driver) . databaseTypeId
261+ driverPlugins [ typeId] = driver
262+ for additionalId in type ( of: driver) . additionalDatabaseTypeIds {
263+ driverPlugins [ additionalId] = driver
264+ }
265+ Self . logger. debug ( " Registered driver plugin ' \( pluginId) ' for database type ' \( typeId) ' " )
266+ registeredAny = true
256267 }
257- Self . logger. debug ( " Registered driver plugin ' \( pluginId) ' for database type ' \( typeId) ' " )
258268 }
259269
260270 if let exportPlugin = instance as? any ExportFormatPlugin {
@@ -264,6 +274,7 @@ final class PluginManager {
264274 let formatId = type ( of: exportPlugin) . formatId
265275 exportPlugins [ formatId] = exportPlugin
266276 Self . logger. debug ( " Registered export plugin ' \( pluginId) ' for format ' \( formatId) ' " )
277+ registeredAny = true
267278 }
268279
269280 if let importPlugin = instance as? any ImportFormatPlugin {
@@ -273,6 +284,11 @@ final class PluginManager {
273284 let formatId = type ( of: importPlugin) . formatId
274285 importPlugins [ formatId] = importPlugin
275286 Self . logger. debug ( " Registered import plugin ' \( pluginId) ' for format ' \( formatId) ' " )
287+ registeredAny = true
288+ }
289+
290+ if registeredAny {
291+ pluginInstances [ pluginId] = instance
276292 }
277293 }
278294
@@ -293,6 +309,77 @@ final class PluginManager {
293309 }
294310 }
295311
312+ // MARK: - Descriptor Validation
313+
314+ /// Reject-level validation: runs synchronously before registration.
315+ /// Checks only properties already accessed during the loading flow.
316+ func validateDriverDescriptor( _ driverType: any DriverPlugin . Type , pluginId: String ) throws {
317+ guard !driverType. databaseTypeId. trimmingCharacters ( in: . whitespaces) . isEmpty else {
318+ throw PluginError . invalidDescriptor ( pluginId: pluginId, reason: " databaseTypeId is empty " )
319+ }
320+
321+ guard !driverType. databaseDisplayName. trimmingCharacters ( in: . whitespaces) . isEmpty else {
322+ throw PluginError . invalidDescriptor ( pluginId: pluginId, reason: " databaseDisplayName is empty " )
323+ }
324+
325+ let typeId = driverType. databaseTypeId
326+ if let existingPlugin = driverPlugins [ typeId] {
327+ let existingName = Swift . type ( of: existingPlugin) . databaseDisplayName
328+ throw PluginError . invalidDescriptor (
329+ pluginId: pluginId,
330+ reason: " databaseTypeId ' \( typeId) ' is already registered by ' \( existingName) ' "
331+ )
332+ }
333+
334+ let allAdditionalIds = driverType. additionalDatabaseTypeIds
335+ // Warn-only (not reject): redundant but harmless — the primary ID is already registered,
336+ // so the duplicate entry in additionalIds just overwrites with the same value.
337+ // Cross-plugin duplicates are rejected above because they indicate a real conflict.
338+ if allAdditionalIds. contains ( typeId) {
339+ Self . logger. warning ( " Plugin ' \( pluginId) ': additionalDatabaseTypeIds contains the primary databaseTypeId ' \( typeId) ' " )
340+ }
341+
342+ for additionalId in allAdditionalIds {
343+ if let existingPlugin = driverPlugins [ additionalId] {
344+ let existingName = Swift . type ( of: existingPlugin) . databaseDisplayName
345+ throw PluginError . invalidDescriptor (
346+ pluginId: pluginId,
347+ reason: " additionalDatabaseTypeId ' \( additionalId) ' is already registered by ' \( existingName) ' "
348+ )
349+ }
350+ }
351+ }
352+
353+ /// Warn-level connection field validation. Called lazily on first access via
354+ /// `additionalConnectionFields(for:)`, not during plugin loading (protocol witness
355+ /// tables may be unstable for dynamically loaded bundles during the loading path).
356+ func validateConnectionFields( _ fields: [ ConnectionField ] , pluginId: String ) {
357+ var seenIds = Set < String > ( )
358+ for field in fields {
359+ if field. id. trimmingCharacters ( in: . whitespaces) . isEmpty {
360+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field has empty id " )
361+ }
362+ if field. label. trimmingCharacters ( in: . whitespaces) . isEmpty {
363+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field ' \( field. id) ' has empty label " )
364+ }
365+ if !seenIds. insert ( field. id) . inserted {
366+ Self . logger. warning ( " Plugin ' \( pluginId) ': duplicate connection field id ' \( field. id) ' " )
367+ }
368+ if case . dropdown( let options) = field. fieldType, options. isEmpty {
369+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field ' \( field. id) ' is a dropdown with no options " )
370+ }
371+ }
372+ }
373+
374+ private func validateDialectDescriptor( _ dialect: SQLDialectDescriptor , pluginId: String ) {
375+ if dialect. identifierQuote. trimmingCharacters ( in: . whitespaces) . isEmpty {
376+ Self . logger. warning ( " Plugin ' \( pluginId) ': sqlDialect.identifierQuote is empty " )
377+ }
378+ if dialect. keywords. isEmpty {
379+ Self . logger. warning ( " Plugin ' \( pluginId) ': sqlDialect.keywords is empty " )
380+ }
381+ }
382+
296383 private func replaceExistingPlugin( bundleId: String ) {
297384 guard let existingIndex = plugins. firstIndex ( where: { $0. id == bundleId } ) else { return }
298385 // Order matters: unregisterCapabilities reads from `plugins` to find the principal class
@@ -360,7 +447,13 @@ final class PluginManager {
360447 func sqlDialect( for databaseType: DatabaseType ) -> SQLDialectDescriptor ? {
361448 loadPendingPlugins ( )
362449 guard let plugin = driverPlugins [ databaseType. pluginTypeId] else { return nil }
363- return Swift . type ( of: plugin) . sqlDialect
450+ let dialect = Swift . type ( of: plugin) . sqlDialect
451+ let pluginId = databaseType. pluginTypeId
452+ if let dialect, !validatedDialectPlugins. contains ( pluginId) {
453+ validatedDialectPlugins. insert ( pluginId)
454+ validateDialectDescriptor ( dialect, pluginId: pluginId)
455+ }
456+ return dialect
364457 }
365458
366459 func statementCompletions( for databaseType: DatabaseType ) -> [ CompletionEntry ] {
@@ -372,7 +465,13 @@ final class PluginManager {
372465 func additionalConnectionFields( for databaseType: DatabaseType ) -> [ ConnectionField ] {
373466 loadPendingPlugins ( )
374467 guard let plugin = driverPlugins [ databaseType. pluginTypeId] else { return [ ] }
375- return Swift . type ( of: plugin) . additionalConnectionFields
468+ let fields = Swift . type ( of: plugin) . additionalConnectionFields
469+ let pluginId = databaseType. pluginTypeId
470+ if !validatedConnectionFieldPlugins. contains ( pluginId) {
471+ validatedConnectionFieldPlugins. insert ( pluginId)
472+ validateConnectionFields ( fields, pluginId: pluginId)
473+ }
474+ return fields
376475 }
377476
378477 // MARK: - Plugin Property Lookups
0 commit comments