diff --git a/CHANGELOG.md b/CHANGELOG.md index b2664d68..e82da0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Keyboard focus navigation (Tab, Ctrl+J/K/N/P, arrow keys) for connection list, quick switcher, and database switcher +- MongoDB `mongodb+srv://` URI support with SRV toggle, Auth Mechanism dropdown, and Replica Set field (#419) + +### Changed + +- MongoDB `authSource` defaults to database name per MongoDB URI spec instead of always "admin" +- MongoDB collection loading uses `estimatedDocumentCount` and smaller schema sample for faster sidebar population ## [0.22.1] - 2026-03-22 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 22c12de8..69b7a00f 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -62,6 +62,10 @@ final class MongoDBConnection: @unchecked Sendable { private let authSource: String? private let readPreference: String? private let writeConcern: String? + private let useSrv: Bool + private let authMechanism: String? + private let replicaSet: String? + private let extraUriParams: [String: String] private let stateLock = NSLock() private var _isConnected: Bool = false @@ -114,7 +118,11 @@ final class MongoDBConnection: @unchecked Sendable { sslClientCertPath: String = "", authSource: String? = nil, readPreference: String? = nil, - writeConcern: String? = nil + writeConcern: String? = nil, + useSrv: Bool = false, + authMechanism: String? = nil, + replicaSet: String? = nil, + extraUriParams: [String: String] = [:] ) { self.host = host self.port = port @@ -127,6 +135,10 @@ final class MongoDBConnection: @unchecked Sendable { self.authSource = authSource self.readPreference = readPreference self.writeConcern = writeConcern + self.useSrv = useSrv + self.authMechanism = authMechanism + self.replicaSet = replicaSet + self.extraUriParams = extraUriParams } deinit { @@ -150,7 +162,8 @@ final class MongoDBConnection: @unchecked Sendable { // MARK: - URI Construction private func buildUri() -> String { - var uri = "mongodb://" + let scheme = useSrv ? "mongodb+srv" : "mongodb" + var uri = "\(scheme)://" if !user.isEmpty { let encodedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user @@ -167,10 +180,21 @@ final class MongoDBConnection: @unchecked Sendable { let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database - uri += "\(encodedHost):\(port)" + if useSrv { + uri += encodedHost + } else { + uri += "\(encodedHost):\(port)" + } uri += database.isEmpty ? "/" : "/\(encodedDb)" - let effectiveAuthSource = authSource.flatMap { $0.isEmpty ? nil : $0 } ?? "admin" + let effectiveAuthSource: String + if let source = authSource, !source.isEmpty { + effectiveAuthSource = source + } else if !database.isEmpty { + effectiveAuthSource = database + } else { + effectiveAuthSource = "admin" + } let encodedAuthSource = effectiveAuthSource .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? effectiveAuthSource var params: [String] = [ @@ -206,6 +230,24 @@ final class MongoDBConnection: @unchecked Sendable { if let wc = writeConcern, !wc.isEmpty { params.append("w=\(wc)") } + if let mechanism = authMechanism, !mechanism.isEmpty { + params.append("authMechanism=\(mechanism)") + } + if let rs = replicaSet, !rs.isEmpty { + params.append("replicaSet=\(rs)") + } + + var explicitKeys: Set = [ + "connectTimeoutMS", "serverSelectionTimeoutMS", + "authSource", "authMechanism", "replicaSet", + "tls", "tlsAllowInvalidCertificates", "tlsCAFile", "tlsCertificateKeyFile" + ] + if readPreference != nil, !readPreference!.isEmpty { explicitKeys.insert("readPreference") } + if writeConcern != nil, !writeConcern!.isEmpty { explicitKeys.insert("w") } + for (key, value) in extraUriParams where !explicitKeys.contains(key) { + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + params.append("\(key)=\(encodedValue)") + } uri += "?" + params.joined(separator: "&") return uri @@ -248,14 +290,12 @@ final class MongoDBConnection: @unchecked Sendable { } self.client = newClient - let versionString = self.fetchServerVersionSync() self.stateLock.lock() - self._cachedServerVersion = versionString self._isConnected = true self.stateLock.unlock() - logger.info("Connected to MongoDB \(versionString ?? "unknown")") + logger.info("Connected to MongoDB at \(self.host):\(self.port)") } #else throw MongoDBError.libmongocUnavailable @@ -341,8 +381,21 @@ final class MongoDBConnection: @unchecked Sendable { func serverVersion() -> String? { stateLock.lock() - defer { stateLock.unlock() } - return _cachedServerVersion + if let cached = _cachedServerVersion { + stateLock.unlock() + return cached + } + stateLock.unlock() + + #if canImport(CLibMongoc) + let version = queue.sync { fetchServerVersionSync() } + stateLock.lock() + _cachedServerVersion = version + stateLock.unlock() + return version + #else + return nil + #endif } func currentDatabase() -> String { database } @@ -429,6 +482,29 @@ final class MongoDBConnection: @unchecked Sendable { #endif } + func estimatedDocumentCount(database: String, collection: String) async throws -> Int64 { + #if canImport(CLibMongoc) + resetCancellation() + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected + } + try checkCancelled() + let col = try getCollection(client, database: database, collection: collection) + defer { mongoc_collection_destroy(col) } + + var error = bson_error_t() + let count = mongoc_collection_estimated_document_count(col, nil, nil, nil, &error) + if count < 0 { + throw makeError(error) + } + return count + } + #else + throw MongoDBError.libmongocUnavailable + #endif + } + func insertOne(database: String, collection: String, document: String) async throws -> String? { #if canImport(CLibMongoc) resetCancellation() diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index a7b01ef3..97d20177 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -41,6 +41,30 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { .init(value: "3", label: "3"), ]) ), + ConnectionField( + id: "mongoUseSrv", + label: "Use SRV Record", + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ), + ConnectionField( + id: "mongoAuthMechanism", + label: "Auth Mechanism", + fieldType: .dropdown(options: [ + .init(value: "", label: "Default"), + .init(value: "SCRAM-SHA-1", label: "SCRAM-SHA-1"), + .init(value: "SCRAM-SHA-256", label: "SCRAM-SHA-256"), + .init(value: "MONGODB-X509", label: "X.509"), + .init(value: "MONGODB-AWS", label: "AWS IAM"), + ]), + section: .authentication + ), + ConnectionField( + id: "mongoReplicaSet", + label: "Replica Set", + section: .advanced + ), ] // MARK: - UI/Capability Metadata diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index d0f1b76d..c9e3432e 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -36,6 +36,18 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { // MARK: - Connection Management func connect() async throws { + let useSrv = config.additionalFields["mongoUseSrv"] == "true" + let authMechanism = config.additionalFields["mongoAuthMechanism"] + let replicaSet = config.additionalFields["mongoReplicaSet"] + + var extraParams: [String: String] = [:] + for (key, value) in config.additionalFields where key.hasPrefix("mongoParam_") { + let paramName = String(key.dropFirst("mongoParam_".count)) + if !paramName.isEmpty { + extraParams[paramName] = value + } + } + let conn = MongoDBConnection( host: config.host, port: config.port, @@ -47,7 +59,11 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { sslClientCertPath: config.additionalFields["sslClientCertPath"] ?? "", authSource: config.additionalFields["mongoAuthSource"], readPreference: config.additionalFields["mongoReadPreference"], - writeConcern: config.additionalFields["mongoWriteConcern"] + writeConcern: config.additionalFields["mongoWriteConcern"], + useSrv: useSrv, + authMechanism: authMechanism, + replicaSet: replicaSet, + extraUriParams: extraParams ) try await conn.connect() @@ -183,7 +199,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { let docs = try await conn.find( database: currentDb, collection: table, - filter: "{}", sort: nil, projection: nil, skip: 0, limit: 500 + filter: "{}", sort: nil, projection: nil, skip: 0, limit: 50 ).docs if docs.isEmpty { @@ -277,7 +293,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { throw MongoDBPluginError.notConnected } - let count = try await conn.countDocuments(database: currentDb, collection: table, filter: "{}") + let count = try await conn.estimatedDocumentCount(database: currentDb, collection: table) return Int(count) } diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 0fe711d1..6c696afc 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -440,7 +440,7 @@ extension AppDelegate { tagId = ConnectionURLParser.tagId(fromEnvName: envName) } - return DatabaseConnection( + var connection = DatabaseConnection( name: parsed.connectionName ?? parsed.suggestedName, host: parsed.host, port: parsed.port ?? parsed.type.defaultPort, @@ -452,8 +452,19 @@ extension AppDelegate { color: color, tagId: tagId, mongoAuthSource: parsed.authSource, + mongoUseSrv: parsed.useSrv, + mongoAuthMechanism: parsed.mongoQueryParams["authMechanism"], + mongoReplicaSet: parsed.mongoQueryParams["replicaSet"], redisDatabase: parsed.redisDatabase, oracleServiceName: parsed.oracleServiceName ) + + for (key, value) in parsed.mongoQueryParams where !value.isEmpty { + if key != "authMechanism" && key != "replicaSet" { + connection.additionalFields["mongoParam_\(key)"] = value + } + } + + return connection } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 7f8ec9c5..d058d691 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -400,6 +400,16 @@ final class PluginManager { } let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent + + // Skip user-installed plugin if a built-in version already exists + if source == .userInstalled, + let existing = plugins.first(where: { $0.id == bundleId }), + existing.source == .builtIn + { + Self.logger.info("Skipping user-installed '\(bundleId)' — built-in version already loaded") + return existing + } + let disabled = disabledPluginIds let driverType = principalClass as? any DriverPlugin.Type diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 2857b58f..bb5b19e8 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -32,6 +32,8 @@ struct ParsedConnectionURL { let filterValue: String? let filterCondition: String? let oracleServiceName: String? + let useSrv: Bool + let mongoQueryParams: [String: String] var suggestedName: String { if let connectionName, !connectionName.isEmpty { @@ -122,6 +124,8 @@ struct ConnectionURLParser { } } + let isSrv = scheme == "mongodb+srv" + if dbType == .sqlite { let path = String(trimmed[schemeEnd.upperBound...]) return .success(ParsedConnectionURL( @@ -150,7 +154,9 @@ struct ConnectionURLParser { filterOperation: nil, filterValue: nil, filterCondition: nil, - oracleServiceName: nil + oracleServiceName: nil, + useSrv: false, + mongoQueryParams: [:] )) } @@ -181,7 +187,7 @@ struct ConnectionURLParser { database = String(database.dropFirst()) } - let ext = parseQueryItems(components.queryItems) + var ext = parseQueryItems(components.queryItems, dbType: dbType) var sslMode = ext.sslMode // Redis-specific: parse database index from path and handle TLS scheme @@ -203,10 +209,19 @@ struct ConnectionURLParser { database = "" } + // SRV implies TLS and no explicit port + if isSrv { + ext.useSrv = true + if sslMode == nil { + sslMode = .required + } + } + let effectivePort = isSrv ? nil : port + return .success(ParsedConnectionURL( type: dbType, host: host, - port: port, + port: effectivePort, database: database, username: username, password: password, @@ -229,7 +244,9 @@ struct ConnectionURLParser { filterOperation: ext.filterOperation, filterValue: ext.filterValue, filterCondition: ext.filterCondition, - oracleServiceName: oracleServiceName + oracleServiceName: oracleServiceName, + useSrv: ext.useSrv, + mongoQueryParams: ext.mongoQueryParams )) } @@ -325,7 +342,7 @@ struct ConnectionURLParser { host = "127.0.0.1" } - let ext = parseSSHQueryString(queryString) + let ext = parseSSHQueryString(queryString, dbType: dbType) // Oracle-specific: path component is the service name, not the database name var oracleServiceName: String? @@ -360,7 +377,9 @@ struct ConnectionURLParser { filterOperation: ext.filterOperation, filterValue: ext.filterValue, filterCondition: ext.filterCondition, - oracleServiceName: oracleServiceName + oracleServiceName: oracleServiceName, + useSrv: ext.useSrv, + mongoQueryParams: ext.mongoQueryParams )) } @@ -382,19 +401,21 @@ struct ConnectionURLParser { var filterOperation: String? var filterValue: String? var filterCondition: String? + var useSrv: Bool = false + var mongoQueryParams: [String: String] = [:] } - private static func parseQueryItems(_ queryItems: [URLQueryItem]?) -> ExtendedParams { + private static func parseQueryItems(_ queryItems: [URLQueryItem]?, dbType: DatabaseType? = nil) -> ExtendedParams { var ext = ExtendedParams() guard let queryItems else { return ext } for item in queryItems { guard let value = item.value, !value.isEmpty else { continue } - applyQueryParam(key: item.name, value: value, to: &ext) + applyQueryParam(key: item.name, value: value, to: &ext, dbType: dbType) } return ext } - private static func parseSSHQueryString(_ queryString: String?) -> ExtendedParams { + private static func parseSSHQueryString(_ queryString: String?, dbType: DatabaseType? = nil) -> ExtendedParams { var ext = ExtendedParams() guard let queryString else { return ext } let params = queryString.split(separator: "&", omittingEmptySubsequences: true) @@ -415,12 +436,12 @@ struct ConnectionURLParser { ext.agentSocket = value.removingPercentEncoding ?? value continue } - applyQueryParam(key: String(key), value: value, to: &ext) + applyQueryParam(key: String(key), value: value, to: &ext, dbType: dbType) } return ext } - private static func applyQueryParam(key: String, value: String, to ext: inout ExtendedParams) { + private static func applyQueryParam(key: String, value: String, to ext: inout ExtendedParams, dbType: DatabaseType? = nil) { switch key { case "sslmode": ext.sslMode = parseSSLMode(value) @@ -456,8 +477,18 @@ struct ConnectionURLParser { if ext.sslMode == nil, let intValue = Int(value) { ext.sslMode = parseTlsModeInteger(intValue) } + case "tls", "ssl": + if value.lowercased() == "true" && ext.sslMode == nil { + ext.sslMode = .required + } + case "authMechanism", "authmechanism": + ext.mongoQueryParams["authMechanism"] = value + case "replicaSet", "replicaset": + ext.mongoQueryParams["replicaSet"] = value default: - break + if dbType == .mongodb { + ext.mongoQueryParams[key] = value + } } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 89aaf135..f0b17a8b 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -398,6 +398,21 @@ struct DatabaseConnection: Identifiable, Hashable { set { additionalFields["mongoWriteConcern"] = newValue ?? "" } } + var mongoUseSrv: Bool { + get { additionalFields["mongoUseSrv"] == "true" } + set { additionalFields["mongoUseSrv"] = newValue ? "true" : "" } + } + + var mongoAuthMechanism: String? { + get { additionalFields["mongoAuthMechanism"]?.nilIfEmpty } + set { additionalFields["mongoAuthMechanism"] = newValue ?? "" } + } + + var mongoReplicaSet: String? { + get { additionalFields["mongoReplicaSet"]?.nilIfEmpty } + set { additionalFields["mongoReplicaSet"] = newValue ?? "" } + } + var mssqlSchema: String? { get { additionalFields["mssqlSchema"]?.nilIfEmpty } set { additionalFields["mssqlSchema"] = newValue ?? "" } @@ -437,6 +452,9 @@ struct DatabaseConnection: Identifiable, Hashable { mongoAuthSource: String? = nil, mongoReadPreference: String? = nil, mongoWriteConcern: String? = nil, + mongoUseSrv: Bool = false, + mongoAuthMechanism: String? = nil, + mongoReplicaSet: String? = nil, redisDatabase: Int? = nil, mssqlSchema: String? = nil, oracleServiceName: String? = nil, @@ -467,6 +485,9 @@ struct DatabaseConnection: Identifiable, Hashable { if let v = mongoAuthSource { fields["mongoAuthSource"] = v } if let v = mongoReadPreference { fields["mongoReadPreference"] = v } if let v = mongoWriteConcern { fields["mongoWriteConcern"] = v } + if mongoUseSrv { fields["mongoUseSrv"] = "true" } + if let v = mongoAuthMechanism { fields["mongoAuthMechanism"] = v } + if let v = mongoReplicaSet { fields["mongoReplicaSet"] = v } if let v = mssqlSchema { fields["mssqlSchema"] = v } if let v = oracleServiceName { fields["oracleServiceName"] = v } self.additionalFields = fields diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9514b284..56e49508 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1542,9 +1542,32 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length applySSHAgentSocketPath(parsed.agentSocket ?? "") } } + // Clear stale MongoDB fields before applying new import + let mongoKeys = additionalFieldValues.keys.filter { + $0.hasPrefix("mongo") || $0.hasPrefix("mongoParam_") + } + for key in mongoKeys { + additionalFieldValues.removeValue(forKey: key) + } if let authSourceValue = parsed.authSource, !authSourceValue.isEmpty { additionalFieldValues["mongoAuthSource"] = authSourceValue } + if parsed.useSrv { + additionalFieldValues["mongoUseSrv"] = "true" + if sslMode == .disabled { + sslMode = .required + } + } + for (key, value) in parsed.mongoQueryParams where !value.isEmpty { + switch key { + case "authMechanism": + additionalFieldValues["mongoAuthMechanism"] = value + case "replicaSet": + additionalFieldValues["mongoReplicaSet"] = value + default: + additionalFieldValues["mongoParam_\(key)"] = value + } + } if parsed.type.pluginTypeId == "Redis", !parsed.database.isEmpty { additionalFieldValues["redisDatabase"] = parsed.database } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 9b1dc281..e1626e2d 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -541,7 +541,6 @@ final class MainContentCommandActions { private func handleDatabaseDidConnect() { Task { @MainActor in - await coordinator?.loadSchema() if let driver = DatabaseManager.shared.driver(for: self.connection.id) { coordinator?.toolbarState.databaseVersion = driver.serverVersion }