From 79517dbaa5376fdeeec7eaf1fedbbf5037a6671a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 19:59:51 +0700 Subject: [PATCH 1/2] fix(plugin-mongodb): pin the auth database and create databases that persist (#1970) --- CHANGELOG.md | 3 + .../MongoDBAuthSourceResolver.swift | 15 ++++ .../MongoDBConnection.swift | 45 ++++++---- .../MongoDBCreateDatabasePlan.swift | 11 +++ .../MongoDBNameValidator.swift | 84 +++++++++++++++++++ .../MongoDBPluginDriver.swift | 27 +++++- .../PluginCreateDatabaseFormSpec.swift | 22 +++++ .../Core/Plugins/PluginDriverAdapter.swift | 12 ++- ...ginMetadataRegistry+RegistryDefaults.swift | 3 +- .../Core/Plugins/PluginMetadataRegistry.swift | 1 + .../Query/MetadataConnectionPool.swift | 41 ++++++++- .../Connection/DatabaseConnection.swift | 5 ++ .../Schema/CreateDatabaseFormSpec.swift | 8 ++ .../CreateDatabaseSheet.swift | 37 ++++++-- .../Query/MetadataConnectionPoolTests.swift | 61 ++++++++++++++ .../MongoDBAuthSourceResolver.swift | 1 + .../MongoDBCreateDatabasePlan.swift | 1 + .../MongoDBNameValidator.swift | 1 + .../MongoDBAuthSourceResolverTests.swift | 56 +++++++++++++ .../MongoDBCreateDatabasePlanTests.swift | 46 ++++++++++ .../Plugins/MongoDBNameValidatorTests.swift | 80 ++++++++++++++++++ docs/databases/mongodb.mdx | 6 +- 22 files changed, 536 insertions(+), 30 deletions(-) create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBCreateDatabasePlan.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift create mode 120000 TableProTests/PluginTestSources/MongoDBAuthSourceResolver.swift create mode 120000 TableProTests/PluginTestSources/MongoDBCreateDatabasePlan.swift create mode 120000 TableProTests/PluginTestSources/MongoDBNameValidator.swift create mode 100644 TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift create mode 100644 TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift create mode 100644 TableProTests/Plugins/MongoDBNameValidatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b7f29b1..bcc9b05f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970) +- Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970) +- A MongoDB authentication error now names the user and the database it tried to authenticate against, instead of a bare driver code. (#1970) - Exporting a MongoDB collection works again. Every format failed with "Unsupported MongoDB method: getCollection" before any data was read. You can now also write `db.getCollection("name")` in the query editor, which is the only way to reach collections whose names contain dots or spaces, start with a digit, or match a database method such as `stats`. (#1971) - Exported MongoDB collections keep every field. TablePro took the column list from the first document alone, so a field that showed up later was dropped from JSON exports and pushed CSV rows out of line with their header. (#1971) - The `postgres` database shows in the database list again. It was marked as a system database, which hid it from the sidebar, Cmd+K, the database filter, and the Backup and Restore Dump pickers. PostgreSQL creates it for users and applications, so nothing about it is internal. CockroachDB's `defaultdb` and Redshift's `dev` were hidden the same way and now show too. (#1967) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift b/Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift new file mode 100644 index 000000000..56107f8fd --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift @@ -0,0 +1,15 @@ +import Foundation + +enum MongoDBAuthSourceResolver { + static let defaultAuthSource = "admin" + + static func resolve(explicitAuthSource: String?, configuredDatabase: String, useSrv: Bool) -> String { + if let explicitAuthSource, !explicitAuthSource.isEmpty { + return explicitAuthSource + } + guard !useSrv, !configuredDatabase.isEmpty else { + return defaultAuthSource + } + return configuredDatabase + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 4fbf3cb2c..082e95f02 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -60,7 +60,7 @@ final class MongoDBConnection: @unchecked Sendable { private let password: String? let database: String private let ssl: SSLConfiguration - private let authSource: String? + private let resolvedAuthSource: String private let readPreference: String? private let writeConcern: String? private let useSrv: Bool @@ -114,6 +114,7 @@ final class MongoDBConnection: @unchecked Sendable { user: String, password: String?, database: String, + configuredDatabase: String, ssl: SSLConfiguration = SSLConfiguration(), authSource: String? = nil, readPreference: String? = nil, @@ -129,7 +130,11 @@ final class MongoDBConnection: @unchecked Sendable { self.password = password self.database = database self.ssl = ssl - self.authSource = authSource + self.resolvedAuthSource = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: authSource, + configuredDatabase: configuredDatabase, + useSrv: useSrv + ) self.readPreference = readPreference self.writeConcern = writeConcern self.useSrv = useSrv @@ -204,18 +209,8 @@ final class MongoDBConnection: @unchecked Sendable { let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database uri += database.isEmpty ? "/" : "/\(encodedDb)" - let effectiveAuthSource: String - if let source = authSource, !source.isEmpty { - effectiveAuthSource = source - } else if useSrv { - effectiveAuthSource = "admin" - } else if !database.isEmpty { - effectiveAuthSource = database - } else { - effectiveAuthSource = "admin" - } - let encodedAuthSource = effectiveAuthSource - .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? effectiveAuthSource + let encodedAuthSource = resolvedAuthSource + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? resolvedAuthSource var params: [String] = [ "connectTimeoutMS=10000", "serverSelectionTimeoutMS=10000", @@ -267,6 +262,19 @@ final class MongoDBConnection: @unchecked Sendable { return String(trimmed[.. String { + let requested = values[firstCollectionFieldId]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return requested.isEmpty ? databaseName : requested + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift b/Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift new file mode 100644 index 000000000..22e87098c --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift @@ -0,0 +1,84 @@ +import Foundation +import TableProPluginKit + +enum MongoDBNameValidationError: Error, Equatable { + case emptyDatabaseName + case databaseNameTooLong + case databaseNameInvalidCharacter(Character) + case emptyCollectionName + case collectionNameInvalidCharacter(Character) + case collectionNameReservedPrefix + case namespaceTooLong +} + +extension MongoDBNameValidationError: PluginDriverError { + var pluginErrorMessage: String { + switch self { + case .emptyDatabaseName: + return String(localized: "Enter a database name.") + case .databaseNameTooLong: + return String( + format: String(localized: "A database name must be shorter than %ld bytes."), + MongoDBNameValidator.maxDatabaseNameBytes + ) + case .databaseNameInvalidCharacter(let character): + return String( + format: String(localized: "A database name cannot contain %@."), + String(character) + ) + case .emptyCollectionName: + return String(localized: "Enter a collection name.") + case .collectionNameInvalidCharacter(let character): + return String( + format: String(localized: "A collection name cannot contain %@."), + String(character) + ) + case .collectionNameReservedPrefix: + return String( + format: String(localized: "A collection name cannot start with %@."), + MongoDBNameValidator.reservedCollectionPrefix + ) + case .namespaceTooLong: + return String( + format: String(localized: "The database and collection names together must be %ld bytes or fewer."), + MongoDBNameValidator.maxNamespaceBytes + ) + } + } +} + +enum MongoDBNameValidator { + static let maxDatabaseNameBytes = 64 + static let maxNamespaceBytes = 255 + static let reservedCollectionPrefix = "system." + + private static let forbiddenDatabaseCharacters: Set = ["/", "\\", ".", "\"", "$", "\0"] + private static let forbiddenCollectionCharacters: Set = ["$", "\0"] + + static func validateDatabaseName(_ name: String) throws { + guard !name.isEmpty else { + throw MongoDBNameValidationError.emptyDatabaseName + } + if let offender = name.first(where: forbiddenDatabaseCharacters.contains) { + throw MongoDBNameValidationError.databaseNameInvalidCharacter(offender) + } + guard name.utf8.count < maxDatabaseNameBytes else { + throw MongoDBNameValidationError.databaseNameTooLong + } + } + + static func validateCollectionName(_ name: String, inDatabase database: String) throws { + guard !name.isEmpty else { + throw MongoDBNameValidationError.emptyCollectionName + } + if let offender = name.first(where: forbiddenCollectionCharacters.contains) { + throw MongoDBNameValidationError.collectionNameInvalidCharacter(offender) + } + guard !name.hasPrefix(reservedCollectionPrefix) else { + throw MongoDBNameValidationError.collectionNameReservedPrefix + } + guard database.utf8.count + 1 + name.utf8.count <= maxNamespaceBytes else { + throw MongoDBNameValidationError.namespaceTooLong + } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 40609fe6d..a1e63d634 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -68,6 +68,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { user: config.username, password: config.password, database: currentDb, + configuredDatabase: config.database, ssl: effectiveSSL, authSource: config.additionalFields["mongoAuthSource"], readPreference: config.additionalFields["mongoReadPreference"], @@ -451,7 +452,18 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { - PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + PluginCreateDatabaseFormSpec( + fields: [], + textInputs: [ + PluginCreateDatabaseFormSpec.TextInput( + id: MongoDBCreateDatabasePlan.firstCollectionFieldId, + label: String(localized: "First Collection"), + placeholder: String(localized: "Collection name"), + isRequired: true + ) + ], + footnote: String(localized: "MongoDB stores a database only once it holds a collection, so a new database needs its first one.") + ) } func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { @@ -459,8 +471,17 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw MongoDBPluginError.notConnected } - _ = try await conn.insertOne(database: request.name, collection: "__tablepro_init", document: "{\"_init\": true}") - _ = try await conn.runCommand("{\"drop\": \"__tablepro_init\"}", database: request.name) + try MongoDBNameValidator.validateDatabaseName(request.name) + let collection = MongoDBCreateDatabasePlan.firstCollectionName( + from: request.values, + databaseName: request.name + ) + try MongoDBNameValidator.validateCollectionName(collection, inDatabase: request.name) + + _ = try await conn.runCommand( + "{\"create\": \"\(escapeJsonString(collection))\"}", + database: request.name + ) } func dropDatabase(name: String) async throws { diff --git a/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift b/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift index c52c91a96..ebe624c74 100644 --- a/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift +++ b/Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift @@ -53,12 +53,34 @@ public struct PluginCreateDatabaseFormSpec: Sendable { } } + public struct TextInput: Sendable { + public let id: String + public let label: String + public let placeholder: String? + public let isRequired: Bool + + public init(id: String, label: String, placeholder: String? = nil, isRequired: Bool = false) { + self.id = id + self.label = label + self.placeholder = placeholder + self.isRequired = isRequired + } + } + public let fields: [Field] public let footnote: String? + public let textInputs: [TextInput] public init(fields: [Field], footnote: String? = nil) { self.fields = fields self.footnote = footnote + self.textInputs = [] + } + + public init(fields: [Field], textInputs: [TextInput], footnote: String? = nil) { + self.fields = fields + self.footnote = footnote + self.textInputs = textInputs } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 2dafa96a1..6a0b1422e 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -695,7 +695,17 @@ private extension PluginDriverAdapter { func mapFormSpec(_ spec: PluginCreateDatabaseFormSpec) -> CreateDatabaseFormSpec { CreateDatabaseFormSpec( fields: spec.fields.map(mapFormField), - footnote: spec.footnote + footnote: spec.footnote, + textInputs: spec.textInputs.map(mapTextInput) + ) + } + + func mapTextInput(_ input: PluginCreateDatabaseFormSpec.TextInput) -> CreateDatabaseFormSpec.TextInput { + CreateDatabaseFormSpec.TextInput( + id: input.id, + label: input.label, + placeholder: input.placeholder, + isRequired: input.isRequired ) } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 520fb9d52..5a481112e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -39,7 +39,8 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, - supportsOpportunisticTLS: false + supportsOpportunisticTLS: false, + authenticationIsDatabaseScoped: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 3f6073d64..c6b01b67b 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -62,6 +62,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsCloudflareTunnel: Bool = true var supportsClientKeyPassphrase: Bool = false var supportsConnectionPooling: Bool = true + var authenticationIsDatabaseScoped: Bool = false var supportsSOCKSProxy: Bool { supportsSSH } diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index ec3987c1f..6787c0929 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -155,7 +155,12 @@ final class MetadataConnectionPool { throw DatabaseError.notConnected } var connection = session.effectiveConnection ?? session.connection - connection.database = key.database + let plan = Self.planConnection( + configuredDatabase: connection.database, + targetDatabase: key.database, + authenticationIsDatabaseScoped: connection.type.authenticationIsDatabaseScoped + ) + connection.database = plan.connectDatabase let driver = try await DatabaseDriverFactory.createDriver( for: connection, @@ -163,11 +168,14 @@ final class MetadataConnectionPool { awaitPlugins: true ) do { - try await Self.connect(driver, database: key.database, timeoutSeconds: operationTimeoutSeconds) + try await Self.connect(driver, database: plan.connectDatabase, timeoutSeconds: operationTimeoutSeconds) try? await driver.applyQueryTimeout(AppSettingsManager.shared.general.queryTimeoutSeconds) await DatabaseManager.shared.executeStartupCommands( session.connection.startupCommands, on: driver, connectionName: session.connection.name ) + if let database = plan.switchDatabase { + try await Self.switchDatabase(driver, to: database, timeoutSeconds: operationTimeoutSeconds) + } if let schema = key.schema { try await Self.switchSchema(driver, to: schema, timeoutSeconds: operationTimeoutSeconds) } @@ -188,6 +196,35 @@ final class MetadataConnectionPool { } } + struct ConnectionPlan: Sendable, Equatable { + let connectDatabase: String + let switchDatabase: String? + } + + static func planConnection( + configuredDatabase: String, + targetDatabase: String, + authenticationIsDatabaseScoped: Bool + ) -> ConnectionPlan { + guard authenticationIsDatabaseScoped, targetDatabase != configuredDatabase else { + return ConnectionPlan(connectDatabase: targetDatabase, switchDatabase: nil) + } + return ConnectionPlan(connectDatabase: configuredDatabase, switchDatabase: targetDatabase) + } + + static func switchDatabase(_ driver: DatabaseDriver, to database: String, timeoutSeconds: Double) async throws { + guard let adapter = driver as? PluginDriverAdapter else { + throw DatabaseError.unsupportedOperation + } + try await bounded( + driver: driver, + timeoutSeconds: timeoutSeconds, + timeoutMessage: String(format: String(localized: "Switching to database '%@' timed out."), database) + ) { + try await adapter.switchDatabase(to: database) + } + } + static func switchSchema(_ driver: DatabaseDriver, to schema: String, timeoutSeconds: Double) async throws { guard let switchable = driver as? SchemaSwitchable else { return } try await bounded( diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 0ee90d615..b253a1678 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -122,6 +122,11 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsConnectionPooling ?? true } + var authenticationIsDatabaseScoped: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)? + .capabilities.authenticationIsDatabaseScoped ?? false + } + var defaultHost: String? { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.connection.defaultHost } diff --git a/TablePro/Models/Schema/CreateDatabaseFormSpec.swift b/TablePro/Models/Schema/CreateDatabaseFormSpec.swift index 659e3481e..8bafb9da8 100644 --- a/TablePro/Models/Schema/CreateDatabaseFormSpec.swift +++ b/TablePro/Models/Schema/CreateDatabaseFormSpec.swift @@ -26,8 +26,16 @@ internal struct CreateDatabaseFormSpec: Sendable { internal let groupedBy: String? } + internal struct TextInput: Sendable, Identifiable { + internal let id: String + internal let label: String + internal let placeholder: String? + internal let isRequired: Bool + } + internal let fields: [Field] internal let footnote: String? + internal let textInputs: [TextInput] } internal struct CreateDatabaseRequest: Sendable { diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index b4ff73663..33f07d835 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -74,6 +74,7 @@ struct CreateDatabaseSheet: View { case .loading: loadingRow case .ready(let spec): + textInputsList(spec: spec) fieldsList(spec: spec) if let footnote = spec.footnote { Text(footnote) @@ -146,6 +147,24 @@ struct CreateDatabaseSheet: View { .padding(.vertical, 14) } + @ViewBuilder + private func textInputsList(spec: CreateDatabaseFormSpec) -> some View { + ForEach(spec.textInputs) { input in + TextField( + input.label, + text: textInputBinding(for: input), + prompt: input.placeholder.map { Text($0) } + ) + } + } + + private func textInputBinding(for input: CreateDatabaseFormSpec.TextInput) -> Binding { + Binding( + get: { values[input.id] ?? "" }, + set: { values[input.id] = $0 } + ) + } + @ViewBuilder private func fieldsList(spec: CreateDatabaseFormSpec) -> some View { ForEach(visibleFields(in: spec)) { field in @@ -178,8 +197,11 @@ struct CreateDatabaseSheet: View { private var canSubmit: Bool { guard !databaseName.isEmpty, !isCreating else { return false } - if case .ready = loadState { return true } - return false + guard case .ready(let spec) = loadState else { return false } + return spec.textInputs.allSatisfy { input in + guard input.isRequired else { return true } + return !(values[input.id] ?? "").trimmingCharacters(in: .whitespaces).isEmpty + } } private func visibleFields(in spec: CreateDatabaseFormSpec) -> [CreateDatabaseFormSpec.Field] { @@ -191,6 +213,12 @@ struct CreateDatabaseSheet: View { return values[visibility.fieldId] == visibility.equals } + private func shouldSubmit(_ fieldId: String, in spec: CreateDatabaseFormSpec) -> Bool { + if spec.textInputs.contains(where: { $0.id == fieldId }) { return true } + guard let field = spec.fields.first(where: { $0.id == fieldId }) else { return false } + return isVisible(field) + } + private func filteredOptions(for field: CreateDatabaseFormSpec.Field) -> [CreateDatabaseFormSpec.Option] { let allOptions = options(from: field.kind) guard allOptions.contains(where: { $0.group != nil }) else { return allOptions } @@ -272,10 +300,7 @@ struct CreateDatabaseSheet: View { errorMessage = nil let name = databaseName - let submissionValues = values.filter { entry in - spec.fields.first { $0.id == entry.key } - .map { isVisible($0) } ?? false - } + let submissionValues = values.filter { shouldSubmit($0.key, in: spec) } Task { do { diff --git a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift index 7d694e6db..007f9274b 100644 --- a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift +++ b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift @@ -59,4 +59,65 @@ struct MetadataConnectionPoolTests { } #expect(driver.currentSchema == nil) } + + @Test("database switch rejects a driver that cannot switch") + func switchDatabaseRejectsUnsupportedDriver() async { + let driver = MockDatabaseDriver() + + await #expect(throws: DatabaseError.self) { + try await MetadataConnectionPool.switchDatabase(driver, to: "shop", timeoutSeconds: 1) + } + } +} + +@Suite("MetadataConnectionPool connection plan") +@MainActor +struct MetadataConnectionPoolPlanTests { + @Test("A database-scoped engine keeps its configured database and switches after connecting") + func planPreservesConfiguredDatabase() { + let plan = MetadataConnectionPool.planConnection( + configuredDatabase: "admin", + targetDatabase: "newly_created", + authenticationIsDatabaseScoped: true + ) + + #expect(plan.connectDatabase == "admin") + #expect(plan.switchDatabase == "newly_created") + } + + @Test("A database-scoped engine connects directly when it is already the target") + func planSkipsRedundantSwitch() { + let plan = MetadataConnectionPool.planConnection( + configuredDatabase: "shop", + targetDatabase: "shop", + authenticationIsDatabaseScoped: true + ) + + #expect(plan.connectDatabase == "shop") + #expect(plan.switchDatabase == nil) + } + + @Test("A database-scoped engine with no configured database connects to the server default") + func planHandlesBlankConfiguredDatabase() { + let plan = MetadataConnectionPool.planConnection( + configuredDatabase: "", + targetDatabase: "shop", + authenticationIsDatabaseScoped: true + ) + + #expect(plan.connectDatabase == "") + #expect(plan.switchDatabase == "shop") + } + + @Test("Every other engine still connects straight to the target database") + func planLeavesOtherEnginesUnchanged() { + let plan = MetadataConnectionPool.planConnection( + configuredDatabase: "shop", + targetDatabase: "reports", + authenticationIsDatabaseScoped: false + ) + + #expect(plan.connectDatabase == "reports") + #expect(plan.switchDatabase == nil) + } } diff --git a/TableProTests/PluginTestSources/MongoDBAuthSourceResolver.swift b/TableProTests/PluginTestSources/MongoDBAuthSourceResolver.swift new file mode 120000 index 000000000..9f6a6829a --- /dev/null +++ b/TableProTests/PluginTestSources/MongoDBAuthSourceResolver.swift @@ -0,0 +1 @@ +../../Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/MongoDBCreateDatabasePlan.swift b/TableProTests/PluginTestSources/MongoDBCreateDatabasePlan.swift new file mode 120000 index 000000000..f07c5225a --- /dev/null +++ b/TableProTests/PluginTestSources/MongoDBCreateDatabasePlan.swift @@ -0,0 +1 @@ +../../Plugins/MongoDBDriverPlugin/MongoDBCreateDatabasePlan.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/MongoDBNameValidator.swift b/TableProTests/PluginTestSources/MongoDBNameValidator.swift new file mode 120000 index 000000000..3cfec0908 --- /dev/null +++ b/TableProTests/PluginTestSources/MongoDBNameValidator.swift @@ -0,0 +1 @@ +../../Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift \ No newline at end of file diff --git a/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift b/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift new file mode 100644 index 000000000..bd0232877 --- /dev/null +++ b/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift @@ -0,0 +1,56 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MongoDBAuthSourceResolver") +struct MongoDBAuthSourceResolverTests { + @Test("An explicit auth source wins over everything else") + func testExplicitWins() { + let resolved = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: "accounts", + configuredDatabase: "shop", + useSrv: false + ) + #expect(resolved == "accounts") + } + + @Test("A blank explicit auth source falls through") + func testBlankExplicitFallsThrough() { + let resolved = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: "", + configuredDatabase: "shop", + useSrv: false + ) + #expect(resolved == "shop") + } + + @Test("SRV connections authenticate against admin") + func testSrvUsesAdmin() { + let resolved = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: nil, + configuredDatabase: "shop", + useSrv: true + ) + #expect(resolved == "admin") + } + + @Test("Without a configured database the fallback is admin") + func testEmptyConfiguredUsesAdmin() { + let resolved = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: nil, + configuredDatabase: "", + useSrv: false + ) + #expect(resolved == "admin") + } + + @Test("A configured database is the fallback when no auth source is set") + func testConfiguredDatabaseIsFallback() { + let resolved = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: nil, + configuredDatabase: "shop", + useSrv: false + ) + #expect(resolved == "shop") + } +} diff --git a/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift b/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift new file mode 100644 index 000000000..f87cde55c --- /dev/null +++ b/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MongoDBCreateDatabasePlan") +struct MongoDBCreateDatabasePlanTests { + @Test("The typed collection name is used") + func testUsesTypedName() { + let name = MongoDBCreateDatabasePlan.firstCollectionName( + from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: "orders"], + databaseName: "shop" + ) + #expect(name == "orders") + } + + @Test("Surrounding whitespace is trimmed") + func testTrimsWhitespace() { + let name = MongoDBCreateDatabasePlan.firstCollectionName( + from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: " orders\n"], + databaseName: "shop" + ) + #expect(name == "orders") + } + + @Test("A blank collection name falls back to the database name") + func testBlankFallsBack() { + let name = MongoDBCreateDatabasePlan.firstCollectionName( + from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: " "], + databaseName: "shop" + ) + #expect(name == "shop") + } + + @Test("An app that does not send the field still creates a collection") + func testMissingFieldFallsBack() { + let name = MongoDBCreateDatabasePlan.firstCollectionName(from: [:], databaseName: "shop") + #expect(name == "shop") + } + + @Test("The legacy form spec initializer declares no text inputs") + func testLegacySpecHasNoTextInputs() { + let spec = PluginCreateDatabaseFormSpec(fields: []) + #expect(spec.textInputs.isEmpty) + } +} diff --git a/TableProTests/Plugins/MongoDBNameValidatorTests.swift b/TableProTests/Plugins/MongoDBNameValidatorTests.swift new file mode 100644 index 000000000..71e79c737 --- /dev/null +++ b/TableProTests/Plugins/MongoDBNameValidatorTests.swift @@ -0,0 +1,80 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MongoDBNameValidator") +struct MongoDBNameValidatorTests { + @Test("A plain database name passes") + func testValidDatabaseName() throws { + try MongoDBNameValidator.validateDatabaseName("shop") + } + + @Test("An empty database name is rejected") + func testEmptyDatabaseName() { + #expect(throws: MongoDBNameValidationError.emptyDatabaseName) { + try MongoDBNameValidator.validateDatabaseName("") + } + } + + @Test("Each character MongoDB forbids in a database name is rejected", arguments: ["/", "\\", ".", "\"", "$"]) + func testForbiddenDatabaseCharacters(character: String) { + #expect(throws: MongoDBNameValidationError.databaseNameInvalidCharacter(Character(character))) { + try MongoDBNameValidator.validateDatabaseName("sh\(character)op") + } + } + + @Test("A database name of 64 bytes or more is rejected") + func testDatabaseNameLength() throws { + try MongoDBNameValidator.validateDatabaseName(String(repeating: "a", count: 63)) + #expect(throws: MongoDBNameValidationError.databaseNameTooLong) { + try MongoDBNameValidator.validateDatabaseName(String(repeating: "a", count: 64)) + } + } + + @Test("Database name length counts bytes, not characters") + func testDatabaseNameCountsBytes() { + #expect(throws: MongoDBNameValidationError.databaseNameTooLong) { + try MongoDBNameValidator.validateDatabaseName(String(repeating: "é", count: 32)) + } + } + + @Test("A plain collection name passes") + func testValidCollectionName() throws { + try MongoDBNameValidator.validateCollectionName("orders", inDatabase: "shop") + } + + @Test("An empty collection name is rejected") + func testEmptyCollectionName() { + #expect(throws: MongoDBNameValidationError.emptyCollectionName) { + try MongoDBNameValidator.validateCollectionName("", inDatabase: "shop") + } + } + + @Test("A collection name containing a dollar sign is rejected") + func testCollectionNameDollarSign() { + #expect(throws: MongoDBNameValidationError.collectionNameInvalidCharacter("$")) { + try MongoDBNameValidator.validateCollectionName("or$ders", inDatabase: "shop") + } + } + + @Test("A collection name in the system namespace is rejected") + func testCollectionNameReservedPrefix() { + #expect(throws: MongoDBNameValidationError.collectionNameReservedPrefix) { + try MongoDBNameValidator.validateCollectionName("system.users", inDatabase: "shop") + } + } + + @Test("A dot inside a collection name is allowed") + func testCollectionNameDotIsAllowed() throws { + try MongoDBNameValidator.validateCollectionName("orders.archive", inDatabase: "shop") + } + + @Test("A namespace longer than 255 bytes is rejected") + func testNamespaceLength() throws { + let database = String(repeating: "a", count: 63) + try MongoDBNameValidator.validateCollectionName(String(repeating: "b", count: 191), inDatabase: database) + #expect(throws: MongoDBNameValidationError.namespaceTooLong) { + try MongoDBNameValidator.validateCollectionName(String(repeating: "b", count: 192), inDatabase: database) + } + } +} diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 6fde0ca8c..9fc1fa9a9 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -31,6 +31,8 @@ The form has no Database field. On connect TablePro opens the first non-system d **Advanced**: **Auth Database** (usually `admin`), **Read Preference**, **Write Concern**, **Use SRV Record**, and **Replica Set** name. +Leaving **Auth Database** empty authenticates against the database in the connection URL path, or `admin` when there is none. Switching databases in the app never changes it, so browsing a database your user has no account in does not break the connection. + MongoDB connection form with the multi-host Hosts editor MongoDB connection form with the multi-host Hosts editor @@ -73,6 +75,8 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. **Collection Browsing**: Sidebar shows all collections. Click to view documents in the data grid. Top-level fields render as columns, nested objects and arrays as formatted JSON, ObjectIds as strings. The grid schema is inferred by sampling documents. +**New Database**: The dialog asks for a database name and the name of its first collection. Both are required. MongoDB stores a database only once it holds a collection, so a name on its own would disappear as soon as the sidebar refreshed. The collection is created empty. + **Views**: **New View** in the sidebar opens a query tab with a `db.createView("view_name", "source_collection", [pipeline])` template. Editing a view definition pre-fills a `db.runCommand({"collMod": ...})` command. **Explain**: Press `Cmd+Option+E` on an MQL statement. TablePro converts `find`, `aggregate`, `countDocuments`, update, delete, and `findOneAnd*` calls into `db.runCommand({"explain": ..., "verbosity": "executionStats"})`. @@ -119,7 +123,7 @@ db.users.countDocuments({"role": "admin"}) **Connection refused**: Check MongoDB is running (`brew services start mongodb-community`), verify host/port in `mongod.conf`, check `bindIp` setting. -**Auth failed**: Verify username, password, auth database, and auth mechanism. The MQL editor does not parse `db.getUsers()` or `db.createUser()`; inspect users with `db.runCommand({"usersInfo": 1})` or manage them in mongosh. +**Auth failed**: The error names the database TablePro authenticated against. If that is not where your user lives, set **Auth Database** in the Advanced section. Otherwise verify username, password, and auth mechanism. The MQL editor does not parse `db.getUsers()` or `db.createUser()`; inspect users with `db.runCommand({"usersInfo": 1})` or manage them in mongosh. **Timeout**: Verify host/port, check network and firewall, whitelist your IP in MongoDB Atlas. From 32cc8436f79942626763fdc2322e208fee9be3c5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 20:26:14 +0700 Subject: [PATCH 2/2] test(ssh): stop the relay tests closing a descriptor twice --- .../Core/SSH/TestSupport/SocketPair.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/TableProTests/Core/SSH/TestSupport/SocketPair.swift b/TableProTests/Core/SSH/TestSupport/SocketPair.swift index 4d10739b7..de4de55c8 100644 --- a/TableProTests/Core/SSH/TestSupport/SocketPair.swift +++ b/TableProTests/Core/SSH/TestSupport/SocketPair.swift @@ -2,6 +2,10 @@ // SocketPair.swift // TableProTests // +// Closing is tracked per descriptor because the suite runs in parallel: closing an +// already-closed fd releases a number the kernel may have handed to a socket another +// test is still polling, which makes that test observe a hangup it never caused. +// import Foundation @@ -9,20 +13,30 @@ import Foundation /// real sockets rather than fakes, so closing behaviour is observed the way a database /// client would observe it. internal final class SocketPair { - let a: Int32 - let b: Int32 + private(set) var a: Int32 + private(set) var b: Int32 init() { - var fds: [Int32] = [0, 0] - _ = socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) + var fds: [Int32] = [-1, -1] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0 else { + a = -1 + b = -1 + return + } a = fds[0] b = fds[1] } - func closeB() { Darwin.close(b) } + func closeB() { Self.close(&b) } func close() { - Darwin.close(a) - Darwin.close(b) + Self.close(&a) + Self.close(&b) + } + + private static func close(_ fd: inout Int32) { + guard fd >= 0 else { return } + Darwin.close(fd) + fd = -1 } }