@@ -249,6 +249,12 @@ final class PluginManager {
249249 if !declared. contains ( . databaseDriver) {
250250 Self . logger. warning ( " Plugin ' \( pluginId) ' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway " )
251251 }
252+ do {
253+ try validateDriverDescriptor ( type ( of: driver) , pluginId: pluginId)
254+ } catch {
255+ Self . logger. error ( " Plugin ' \( pluginId) ' rejected: \( error. localizedDescription) " )
256+ return
257+ }
252258 let typeId = type ( of: driver) . databaseTypeId
253259 driverPlugins [ typeId] = driver
254260 for additionalId in type ( of: driver) . additionalDatabaseTypeIds {
@@ -293,6 +299,73 @@ final class PluginManager {
293299 }
294300 }
295301
302+ // MARK: - Descriptor Validation
303+
304+ /// Reject-level validation: runs synchronously before registration.
305+ /// Checks only properties already accessed during the loading flow.
306+ private func validateDriverDescriptor( _ driverType: any DriverPlugin . Type , pluginId: String ) throws {
307+ guard !driverType. databaseTypeId. trimmingCharacters ( in: . whitespaces) . isEmpty else {
308+ throw PluginError . invalidDescriptor ( pluginId: pluginId, reason: " databaseTypeId is empty " )
309+ }
310+
311+ guard !driverType. databaseDisplayName. trimmingCharacters ( in: . whitespaces) . isEmpty else {
312+ throw PluginError . invalidDescriptor ( pluginId: pluginId, reason: " databaseDisplayName is empty " )
313+ }
314+
315+ let typeId = driverType. databaseTypeId
316+ if let existingPlugin = driverPlugins [ typeId] {
317+ let existingName = Swift . type ( of: existingPlugin) . databaseDisplayName
318+ throw PluginError . invalidDescriptor (
319+ pluginId: pluginId,
320+ reason: " databaseTypeId ' \( typeId) ' is already registered by ' \( existingName) ' "
321+ )
322+ }
323+
324+ let allAdditionalIds = driverType. additionalDatabaseTypeIds
325+ if allAdditionalIds. contains ( typeId) {
326+ Self . logger. warning ( " Plugin ' \( pluginId) ': additionalDatabaseTypeIds contains the primary databaseTypeId ' \( typeId) ' " )
327+ }
328+
329+ for additionalId in allAdditionalIds {
330+ if let existingPlugin = driverPlugins [ additionalId] {
331+ let existingName = Swift . type ( of: existingPlugin) . databaseDisplayName
332+ throw PluginError . invalidDescriptor (
333+ pluginId: pluginId,
334+ reason: " additionalDatabaseTypeId ' \( additionalId) ' is already registered by ' \( existingName) ' "
335+ )
336+ }
337+ }
338+ }
339+
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 ) {
343+ var seenIds = Set < String > ( )
344+ for field in fields {
345+ if field. id. trimmingCharacters ( in: . whitespaces) . isEmpty {
346+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field has empty id " )
347+ }
348+ if field. label. trimmingCharacters ( in: . whitespaces) . isEmpty {
349+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field ' \( field. id) ' has empty label " )
350+ }
351+ if !seenIds. insert ( field. id) . inserted {
352+ Self . logger. warning ( " Plugin ' \( pluginId) ': duplicate connection field id ' \( field. id) ' " )
353+ }
354+ if case . dropdown( let options) = field. fieldType, options. isEmpty {
355+ Self . logger. warning ( " Plugin ' \( pluginId) ': connection field ' \( field. id) ' is a dropdown with no options " )
356+ }
357+ }
358+ }
359+
360+ private func validateDialectDescriptor( _ dialect: SQLDialectDescriptor , pluginId: String ) {
361+ if dialect. identifierQuote. trimmingCharacters ( in: . whitespaces) . isEmpty {
362+ Self . logger. warning ( " Plugin ' \( pluginId) ': sqlDialect.identifierQuote is empty " )
363+ }
364+ if dialect. keywords. isEmpty {
365+ Self . logger. warning ( " Plugin ' \( pluginId) ': sqlDialect.keywords is empty " )
366+ }
367+ }
368+
296369 private func replaceExistingPlugin( bundleId: String ) {
297370 guard let existingIndex = plugins. firstIndex ( where: { $0. id == bundleId } ) else { return }
298371 // Order matters: unregisterCapabilities reads from `plugins` to find the principal class
0 commit comments