Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Export from a data file window to every bundled format, for all, filtered or selected rows.
- **Import into Table** from a data file window, into an open connection's import sheet.
- **Text Encoding** in a data file's Save As panel.
- `BSONSymbol()` in the MongoDB shell.

### Changed

Expand Down Expand Up @@ -211,6 +212,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Structure tab refusing to save a renamed or dropped primary key column.
- Compressed dump named `.GZ` rather than `.gz` reaching the parser still compressed.
- **SQL** offered as an import format on MongoDB.
- MongoDB views and `system.*` collections listed as ordinary collections.
- MongoDB compound index keys out of order, and hashed, text and geospatial indexes shown as B-tree.
- TTL, partial filter, collation and other index options missing from MongoDB DDL.
- Index options such as `wildcardProjection` and a 2d index's bounds dropped by `createIndex` in the MongoDB shell.
- `db.createView` missing from the MongoDB shell.
- `Double()` and `BSONRegExp()` undefined in the MongoDB shell although autocomplete offers them.
- `NumberDecimal("NaN")` and `NumberDecimal("Infinity")` refused by the MongoDB shell.
- Fields named `__proto__` dropped from documents a MongoDB script writes or reads.
- Error text after a carriage return or line separator left uncommented in Edit View Definition's fallback.
- Capped MongoDB collection size shown as 0 in DDL.
- **Save** permanently dim on a Custom provider for an OpenAI-compatible server that wants no API key.
- Model list not reloading when the API key changes, leaving the picker empty with no way to retry.
- Empty model picker, with nothing said, for a local or OpenAI-compatible server answering 200 with an unexpected shape.
Expand Down Expand Up @@ -564,6 +575,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SSH private keys pasted or picked on iPhone and iPad saved in plain text in the connections file.
- Test Connection on iPhone and iPad saving its credentials to the Keychain, synced with Sync Passwords on.
- Oracle and Dameng metadata reads and the Oracle server-side export captured by an object shadowing a `SYS` dictionary name or package in the current schema.
- Code in a MongoDB collection, view or index name running when its DDL is run from a query tab.
- Statements hidden behind a backslash in a string skipped Safe Mode on PostgreSQL, DuckDB, SQL Server, SQLite and Dameng.
- Statements hidden inside a nested block comment skipped Safe Mode on PostgreSQL, DuckDB and SQL Server.
- Statements hidden behind a bracketed identifier skipped Safe Mode on SQL Server and SQLite.
Expand Down
24 changes: 24 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,30 @@ extension MongoDBConnection {
return try iterateCursorJson(cursor, cap: 0).json
}

/// The whole `listCollections` entry for each namespace, `type` included, which libmongoc's
/// name listing drops. A named read takes the entry's options too; the full listing asks for
/// names and types only.
func listNamespacesSync(client: OpaquePointer, database: String, named name: String?) throws -> [String] {
try checkCancelled()

let optionsJson = name.map { "{\"filter\": {\"name\": \(MongoScriptJson.jsonString($0))}}" }
?? "{\"nameOnly\": true}"
guard let options = jsonToBson(optionsJson) else {
throw MongoDBError(code: 0, message: MongoScriptText.invalidDocument(optionsJson))
}
defer { bson_destroy(options) }

let handle = try getDatabase(client, database: database)
defer { mongoc_database_destroy(handle) }

guard let cursor = mongoc_database_find_collections_with_opts(handle, options) else {
throw MongoDBError(code: 0, message: MongoScriptText.cursorFailed)
}
defer { mongoc_cursor_destroy(cursor) }

return try iterateCursorJson(cursor, cap: 0).json
}

private func identifierJson(in document: OpaquePointer) -> String {
guard let json = bsonToJson(document),
let identifier = MongoScriptJson.member(of: json, key: "_id") else { return "null" }
Expand Down
28 changes: 8 additions & 20 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ extension MongoDBConnection {
return col
}

func getDatabase(_ client: OpaquePointer, database: String) throws -> OpaquePointer {
guard let handle = database.withCString({ mongoc_client_get_database(client, $0) }) else {
throw MongoDBError(code: 0, message: "Failed to get database \(database)")
}
return handle
}

func runCommandSync(
client: OpaquePointer, command: String, database: String?
) throws -> [[String: Any]] {
Expand Down Expand Up @@ -359,9 +366,7 @@ extension MongoDBConnection {
func listCollectionsSync(client: OpaquePointer, database: String) throws -> [String] {
try checkCancelled()

guard let mongocDb = database.withCString({ mongoc_client_get_database(client, $0) }) else {
throw MongoDBError(code: 0, message: "Failed to get database \(database)")
}
let mongocDb = try getDatabase(client, database: database)
defer { mongoc_database_destroy(mongocDb) }

var error = bson_error_t()
Expand All @@ -381,22 +386,6 @@ extension MongoDBConnection {
return collections
}

func listIndexesSync(
client: OpaquePointer, database: String, collection: String
) throws -> [[String: Any]] {
try checkCancelled()

let col = try getCollection(client, database: database, collection: collection)
defer { mongoc_collection_destroy(col) }

guard let cursor = mongoc_collection_find_indexes_with_opts(col, nil) else {
throw MongoDBError(code: 0, message: "Failed to list indexes for \(collection)")
}
defer { mongoc_cursor_destroy(cursor) }

return try iterateCursor(cursor).docs
}

func iterateCursor(_ cursor: OpaquePointer) throws -> (docs: [[String: Any]], isTruncated: Bool) {
try checkCancelled()

Expand Down Expand Up @@ -519,6 +508,5 @@ extension MongoDBConnection {
if let cur { mongoc_cursor_destroy(cur) }
if let col { mongoc_collection_destroy(col) }
}

}
#endif
18 changes: 9 additions & 9 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ final class MongoDBConnection: @unchecked Sendable {
"tls", "tlsAllowInvalidCertificates", "tlsAllowInvalidHostnames",
"tlsCAFile", "tlsCertificateKeyFile"
]
if readPreference != nil, !readPreference!.isEmpty { explicitKeys.insert("readPreference") }
if writeConcern != nil, !writeConcern!.isEmpty { explicitKeys.insert("w") }
if let readPreference, !readPreference.isEmpty { explicitKeys.insert("readPreference") }
if let writeConcern, !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)")
Expand Down Expand Up @@ -735,33 +735,33 @@ final class MongoDBConnection: @unchecked Sendable {
#endif
}

func listCollections(database: String) async throws -> [String] {
/// Every `listCollections` entry in the database, or the one named, as canonical Extended JSON.
func listNamespaces(database: String, named name: String?) async throws -> [String] {
#if canImport(CLibMongoc)
resetCancellation()
return try await pluginDispatchAsync(on: queue) { [self] in
guard !isShuttingDown, let client = self.client else {
throw MongoDBError.notConnected
}
try checkCancelled()
return try listCollectionsSync(client: client, database: database)
return try listNamespacesSync(client: client, database: database, named: name)
}
#else
throw MongoDBError.libmongocUnavailable
#endif
}

func listIndexes(database: String, collection: String) async throws -> [[String: Any]] {
/// Every `listIndexes` document for the collection, as canonical Extended JSON in server order.
func listIndexes(database: String, collection: String) async throws -> [String] {
#if canImport(CLibMongoc)
resetCancellation()
return try await pluginDispatchAsync(on: queue) { [self] in
guard !isShuttingDown, let client = self.client else {
throw MongoDBError.notConnected
}
try checkCancelled()
return try QueueTransfer(value: listIndexesSync(
client: client, database: database, collection: collection
))
}.value
return try listIndexesJsonSync(client: client, database: database, collection: collection)
}
#else
throw MongoDBError.libmongocUnavailable
#endif
Expand Down
123 changes: 123 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import TableProPluginKit

/// One document `listIndexes` returned, read from the canonical Extended JSON text the server sent.
///
/// The key document's field order is the index: `{lastName: 1, firstName: -1}` and
/// `{firstName: -1, lastName: 1}` are two different indexes. A Swift dictionary cannot hold that
/// order, so the Structure tab listed compound keys in a different order on every fetch and Show
/// DDL wrote them alphabetically, with the TTL, partial filter and collation left out. Each value
/// stays canonical until the statement is written, so a partial filter's Int64 and whole Double
/// keep their types.
struct MongoDBIndexEntry {
static let primaryIndexName = "_id_"

/// Members that describe the catalog entry rather than the index, so `createIndex` is not sent them.
private static let catalogMembers: Set<String> = ["v", "key", "ns"]

let name: String
let keyJson: String
let keyFields: [(name: String, value: String)]
let options: [(key: String, value: String)]
let kind: MongoDBIndexKind

init?(json: String) {
let members = MongoScriptJson.members(of: json)
guard let nameJson = members.first(where: { $0.key == "name" })?.value,
let name = MongoScriptJson.decodedString(nameJson),
let keyJson = members.first(where: { $0.key == "key" })?.value else {
return nil
}
self.name = name
self.keyJson = keyJson
keyFields = MongoScriptJson.members(of: keyJson).map { (name: $0.key, value: $0.value) }
options = members
.filter { !Self.catalogMembers.contains($0.key) }
.map { member in
(key: member.key, value: member.key == "collation" ? MongoDBCollation.portable(member.value) : member.value)
}
kind = MongoDBIndexKind(keyFields: keyFields)
}

var isPrimary: Bool { name == Self.primaryIndexName }

var isUnique: Bool { isPrimary || option("unique") == "true" }

/// The fields in key order. A text index keys on `_fts` and `_ftsx` and names its fields only
/// in `weights`, so those stand where `_fts` does.
var columns: [String] {
guard kind == .text, let weights = option("weights") else { return keyFields.map(\.name) }
let weighted = MongoScriptJson.members(of: weights).map(\.key)
return keyFields.flatMap { field -> [String] in
switch field.name {
case "_fts": return weighted
case "_ftsx": return []
default: return [field.name]
}
}
}

var pluginIndexInfo: PluginIndexInfo {
PluginIndexInfo(
name: name, columns: columns, isUnique: isUnique, isPrimary: isPrimary, type: kind.pluginTypeName
)
}

func createIndexStatement(collection: String) -> String {
let accessor = MongoDBShellText.collection(collection)
let literals = MongoDBJsonLayout.shellObject(
options.map { (key: $0.key, value: MongoDBShellLiteral.render($0.value)) }
)
return "\(accessor).createIndex(\(MongoDBShellLiteral.render(keyJson)), \(literals))"
}

private func option(_ key: String) -> String? {
options.first { $0.key == key }?.value
}
}

/// What an index's key makes it, in the index type names the app's structure editor uses.
enum MongoDBIndexKind: Equatable {
case btree
case hashed
case text
case sphere
case wildcard
case other(String)

init(keyFields: [(name: String, value: String)]) {
if let method = keyFields.lazy.compactMap({ MongoScriptJson.decodedString($0.value) }).first {
switch method {
case "text": self = .text
case "hashed": self = .hashed
case "2dsphere": self = .sphere
default: self = .other(method)
}
return
}
let isWildcard = keyFields.contains { $0.name == "$**" || $0.name.hasSuffix(".$**") }
self = isWildcard ? .wildcard : .btree
}

var pluginTypeName: String {
switch self {
case .btree: return "BTREE"
case .hashed: return "HASH"
case .text: return "FULLTEXT"
case .sphere: return "SPATIAL"
case .wildcard: return "WILDCARD"
case .other(let method): return method.uppercased()
}
}
}

/// A collation as another server can take it.
///
/// The server reports the ICU `version` it built the collation with, and a server with a different
/// ICU build refuses a statement naming that version with code 161. Left out, the server that runs
/// the statement fills in its own, which on the same server is the same one.
enum MongoDBCollation {
static func portable(_ collationJson: String) -> String {
MongoDBJsonLayout.object(MongoScriptJson.members(of: collationJson).filter { $0.key != "version" })
}
}
Loading
Loading