Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
45 changes: 29 additions & 16 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -267,6 +262,19 @@ final class MongoDBConnection: @unchecked Sendable {
return String(trimmed[..<colonIndex])
}

private var authenticationFailureMessage: String {
String(
format: String(
localized: """
Authentication failed for user '%1$@' against database '%2$@'. \
Check the username, password, and Auth Database in the connection settings.
"""
),
user,
resolvedAuthSource
)
}

// MARK: - Connection Management

func connect() async throws {
Expand Down Expand Up @@ -298,12 +306,17 @@ final class MongoDBConnection: @unchecked Sendable {

guard success else {
let errorMsg = bsonErrorMessage(&error)
let domain = error.domain
let code = error.code
mongoc_client_destroy(newClient)
logger.error("MongoDB ping failed: \(errorMsg)")
logger.error("MongoDB ping failed [\(domain)/\(code)]: \(errorMsg)")
if let sslError = MongoDBSSLClassifier.classifySSLError(errorMsg) {
throw sslError
}
throw MongoDBError(code: error.code, message: errorMsg)
if domain == MONGOC_ERROR_CLIENT.rawValue, code == MONGOC_ERROR_CLIENT_AUTHENTICATE.rawValue {
throw MongoDBError(code: 0, message: self.authenticationFailureMessage)
}
throw MongoDBError(code: code, message: errorMsg)
}

self.client = newClient
Expand Down
11 changes: 11 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBCreateDatabasePlan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

enum MongoDBCreateDatabasePlan {
static let firstCollectionFieldId = "mongoFirstCollection"

static func firstCollectionName(from values: [String: String], databaseName: String) -> String {
let requested = values[firstCollectionFieldId]?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return requested.isEmpty ? databaseName : requested
}
}
84 changes: 84 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift
Original file line number Diff line number Diff line change
@@ -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<Character> = ["/", "\\", ".", "\"", "$", "\0"]
private static let forbiddenCollectionCharacters: Set<Character> = ["$", "\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
}
}
}
27 changes: 24 additions & 3 deletions Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -451,16 +452,36 @@ 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 {
guard let conn = mongoConnection else {
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 {
Expand Down
22 changes: 22 additions & 0 deletions Plugins/TableProPluginKit/PluginCreateDatabaseFormSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
12 changes: 11 additions & 1 deletion TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ extension PluginMetadataRegistry {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: true,
supportsOpportunisticTLS: false
supportsOpportunisticTLS: false,
authenticationIsDatabaseScoped: true
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: "public",
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
41 changes: 39 additions & 2 deletions TablePro/Core/Services/Query/MetadataConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,19 +155,27 @@ 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,
passwordOverride: session.cachedPassword,
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)
}
Expand All @@ -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(
Expand Down
Loading
Loading