From 63f3f3396f27248414fdf199b434861972134486 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Jul 2026 19:12:04 +0700 Subject: [PATCH] fix(plugin-mongodb): parse db.getCollection and align exported columns (#1971) --- CHANGELOG.md | 2 + .../MongoDBConnection+SyncHelpers.swift | 67 ++++---- .../MongoDBPluginDriver.swift | 2 +- .../MongoDBQueryBuilder.swift | 6 + .../MongoStreamProjection.swift | 40 +++++ .../TableProPluginKit/MongoShellParser.swift | 159 +++++++++++------ .../Core/MongoDB/MongoShellParserTests.swift | 160 ++++++++++++++++++ .../MongoStreamProjection.swift | 1 + .../Plugins/MongoDBQueryBuilderTests.swift | 34 ++++ .../Plugins/MongoStreamProjectionTests.swift | 76 +++++++++ docs/databases/mongodb.mdx | 2 + 11 files changed, 463 insertions(+), 86 deletions(-) create mode 100644 Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift create mode 120000 TableProTests/PluginTestSources/MongoStreamProjection.swift create mode 100644 TableProTests/Plugins/MongoStreamProjectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a17a07147..a9b7f29b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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) - The database a connection is using always shows in the sidebar and the database switchers, even when it is a system database or the database filter excludes it. (#1967) - The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all. diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift index 776ef452e..015260a9f 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift @@ -365,9 +365,8 @@ extension MongoDBConnection { streamState: MongoStreamState ) { var docPtr: OpaquePointer? - var headerSent = false - var columns: [String] = [] - var columnTypeNames: [String] = [] + var sample: [[String: Any]] = [] + var projection: MongoStreamProjection? while mongoc_cursor_next(cursor, &docPtr) { if Task.isCancelled { @@ -379,31 +378,16 @@ extension MongoDBConnection { guard let doc = docPtr else { continue } let dict = bsonToDict(doc) - if !headerSent { - columns = BsonDocumentFlattener.unionColumns(from: [dict]) - let bsonTypes = BsonDocumentFlattener.columnTypes(for: columns, documents: [dict]) - columnTypeNames = bsonTypes.map { bsonTypeToStreamString($0) } - continuation.yield(.header(PluginStreamHeader( - columns: columns, - columnTypeNames: columnTypeNames - ))) - headerSent = true - } else { - for key in dict.keys.sorted() where !columns.contains(key) { - columns.append(key) - let type = BsonDocumentFlattener.columnTypes(for: [key], documents: [dict]) - columnTypeNames.append(bsonTypeToStreamString(type.first ?? 2)) - } + if let projection { + continuation.yield(.rows([projection.row(for: dict, convert: streamCellValue)])) + continue } - let row: [PluginCellValue] = columns.map { column in - guard let value = dict[column] else { return .null } - if let data = value as? Data { - return .bytes(data) - } - return PluginCellValue.fromOptional(BsonDocumentFlattener.stringValue(for: value)) + sample.append(dict) + if sample.count >= MongoStreamProjection.sampleSize { + projection = openStream(sample: sample, continuation: continuation) + sample = [] } - continuation.yield(.rows([row])) } var error = bson_error_t() @@ -413,17 +397,40 @@ extension MongoDBConnection { return } - if !headerSent { - continuation.yield(.header(PluginStreamHeader( - columns: ["_id"], - columnTypeNames: ["VARCHAR"] - ))) + if projection == nil { + _ = openStream(sample: sample, continuation: continuation) } cleanup(streamState) continuation.finish() } + private func openStream( + sample: [[String: Any]], + continuation: AsyncThrowingStream.Continuation + ) -> MongoStreamProjection { + let columns = BsonDocumentFlattener.unionColumns(from: sample) + let columnTypeNames = BsonDocumentFlattener + .columnTypes(for: columns, documents: sample) + .map { bsonTypeToStreamString($0) } + let projection = MongoStreamProjection(columns: columns, columnTypeNames: columnTypeNames) + + continuation.yield(.header(projection.header)) + + if !sample.isEmpty { + continuation.yield(.rows(sample.map { projection.row(for: $0, convert: streamCellValue) })) + } + + return projection + } + + private func streamCellValue(_ value: Any) -> PluginCellValue { + if let data = value as? Data { + return .bytes(data) + } + return PluginCellValue.fromOptional(BsonDocumentFlattener.stringValue(for: value)) + } + private func cleanup(_ state: MongoStreamState) { state.lock.lock() let cur = state.cursor diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index b440299ea..40609fe6d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -27,7 +27,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func defaultExportQuery(table: String) -> String? { - "db.getCollection(\"\(table)\").find({})" + MongoDBQueryBuilder().buildExportQuery(collection: table) } init(config: DriverConnectionConfig) { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index 04eb34eff..8c4396268 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -66,6 +66,12 @@ struct MongoDBQueryBuilder { "\(Self.mongoCollectionAccessor(collection)).countDocuments(\(filterJson))" } + // MARK: - Export Query + + func buildExportQuery(collection: String) -> String { + "\(Self.mongoCollectionAccessor(collection)).find({})" + } + // MARK: - Filter Document /// Convert filter tuples to MongoDB filter document string diff --git a/Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift b/Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift new file mode 100644 index 000000000..bcf809e1c --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift @@ -0,0 +1,40 @@ +// +// MongoStreamProjection.swift +// MongoDBDriverPlugin +// +// Keeps streamed documents aligned to the column set announced in the stream header. +// + +import Foundation +import TableProPluginKit + +struct MongoStreamProjection { + static let sampleSize = 200 + + let columns: [String] + let columnTypeNames: [String] + + init(columns: [String], columnTypeNames: [String]) { + guard !columns.isEmpty else { + self.columns = ["_id"] + self.columnTypeNames = ["VARCHAR"] + return + } + + self.columns = columns + self.columnTypeNames = columns.indices.map { index in + index < columnTypeNames.count ? columnTypeNames[index] : "VARCHAR" + } + } + + var header: PluginStreamHeader { + PluginStreamHeader(columns: columns, columnTypeNames: columnTypeNames) + } + + func row(for document: [String: Any], convert: (Any) -> PluginCellValue) -> [PluginCellValue] { + columns.map { column in + guard let value = document[column] else { return .null } + return convert(value) + } + } +} diff --git a/Plugins/TableProPluginKit/MongoShellParser.swift b/Plugins/TableProPluginKit/MongoShellParser.swift index 2e66647c1..cd0ce80c6 100644 --- a/Plugins/TableProPluginKit/MongoShellParser.swift +++ b/Plugins/TableProPluginKit/MongoShellParser.swift @@ -123,65 +123,19 @@ public struct MongoShellParser { // MARK: - Private Parsing /// Parse db["collection"].method(args) bracket notation. - /// Supports both double and single quotes around the collection name. + /// Supports double quotes, single quotes and backticks around the collection name. private static func parseBracketExpression(_ input: String) throws -> MongoOperation { - // input starts with db[ - let afterBracket = String(input.dropFirst(3)) // drop "db[" + let nameStart = input.index(input.startIndex, offsetBy: 3) + let literal = try readStringLiteral(in: input, startingAt: nameStart) - // Determine quote character (" or ') - guard let quoteChar = afterBracket.first, quoteChar == "\"" || quoteChar == "'" else { - throw MongoShellParseError.invalidSyntax("Expected quoted collection name in db[...]") + guard literal.endIndex < input.endIndex, input[literal.endIndex] == "]" else { + throw MongoShellParseError.invalidSyntax( + String(localized: "Expected ']' after the collection name") + ) } - // Find closing quote (handle escaped quotes) - var collectionName = "" - var i = afterBracket.index(after: afterBracket.startIndex) - var escapeNext = false - while i < afterBracket.endIndex { - let ch = afterBracket[i] - if escapeNext { - collectionName.append(ch) - escapeNext = false - i = afterBracket.index(after: i) - continue - } - if ch == "\\" { - escapeNext = true - i = afterBracket.index(after: i) - continue - } - if ch == quoteChar { - break - } - collectionName.append(ch) - i = afterBracket.index(after: i) - } - - guard i < afterBracket.endIndex else { - throw MongoShellParseError.invalidSyntax("Unterminated string in db[...]") - } - - // Move past closing quote and expect "]" - i = afterBracket.index(after: i) - guard i < afterBracket.endIndex, afterBracket[i] == "]" else { - throw MongoShellParseError.invalidSyntax("Expected ']' after collection name in db[...]") - } - i = afterBracket.index(after: i) - - let remaining = String(afterBracket[i...]).trimmingCharacters(in: .whitespacesAndNewlines) - - // No method chain — treat as find all - if remaining.isEmpty { - return .find(collection: collectionName, filter: "{}", options: MongoFindOptions()) - } - - // Expect ".method(args)" after db["collection"] - guard remaining.hasPrefix(".") else { - throw MongoShellParseError.invalidSyntax("Expected '.method()' after db[\"...\"]") - } - - let methodChain = String(remaining.dropFirst()) - return try parseMethodChain(collection: collectionName, chain: methodChain) + let chain = String(input[input.index(after: literal.endIndex)...]) + return try parseAccessorChain(collection: literal.value, chain: chain) } private static func parseDbExpression(_ input: String) throws -> MongoOperation { @@ -202,6 +156,9 @@ public struct MongoShellParser { // This correctly handles dotted collection names like "system.version". let beforeParen = afterDb[afterDb.startIndex.. MongoOperation { + let argAndRest = try extractParenthesizedArgAndRemainder(from: input, startingAt: openParen) + let argument = argAndRest.arg + + guard !argument.isEmpty else { + throw MongoShellParseError.missingArgument( + String(localized: "getCollection requires a collection name") + ) + } + + let literal = try readStringLiteral(in: argument, startingAt: argument.startIndex) + let trailing = argument[literal.endIndex...].trimmingCharacters(in: .whitespacesAndNewlines) + + guard trailing.isEmpty else { + throw MongoShellParseError.invalidSyntax( + String(localized: "getCollection takes a single quoted collection name") + ) + } + guard !literal.value.isEmpty else { + throw MongoShellParseError.missingArgument( + String(localized: "getCollection requires a collection name") + ) + } + + return try parseAccessorChain(collection: literal.value, chain: argAndRest.remainder) + } + + private static func parseAccessorChain(collection: String, chain: String) throws -> MongoOperation { + let trimmed = chain.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + return .find(collection: collection, filter: "{}", options: MongoFindOptions()) + } + guard trimmed.hasPrefix(".") else { + throw MongoShellParseError.invalidSyntax( + String(localized: "Expected '.method()' after the collection reference") + ) + } + + return try parseMethodChain(collection: collection, chain: String(trimmed.dropFirst())) + } + /// Parse a db-level method call like db.getCollectionNames(), db.stats(), etc. /// Input is the string after "db." — e.g. "getCollectionNames()" or "createCollection(\"test\")" private static func parseDbLevelMethod(_ input: String) throws -> MongoOperation { @@ -382,6 +384,53 @@ public struct MongoShellParser { // MARK: - Argument Extraction Helpers + private struct StringLiteral { + let value: String + let endIndex: String.Index + } + + /// Read a quoted JavaScript string literal, returning its unescaped value and the index after + /// the closing quote. Accepts double quotes, single quotes and backticks. + private static func readStringLiteral(in text: String, startingAt start: String.Index) throws -> StringLiteral { + guard start < text.endIndex, isStringDelimiter(text[start]) else { + throw MongoShellParseError.invalidSyntax( + String(localized: "Expected a quoted collection name") + ) + } + + let quote = text[start] + var value = "" + var index = text.index(after: start) + var escapeNext = false + + while index < text.endIndex { + let character = text[index] + index = text.index(after: index) + + if escapeNext { + value.append(character) + escapeNext = false + continue + } + if character == "\\" { + escapeNext = true + continue + } + if character == quote { + return StringLiteral(value: value, endIndex: index) + } + value.append(character) + } + + throw MongoShellParseError.invalidSyntax( + String(localized: "Unterminated string in the collection reference") + ) + } + + private static func isStringDelimiter(_ character: Character) -> Bool { + character == "\"" || character == "'" || character == "`" + } + /// Extract content inside balanced parentheses starting at the given index private static func extractParenthesizedArg(from str: String, startingAt openParen: String.Index) throws -> String { let result = try extractParenthesizedArgAndRemainder(from: str, startingAt: openParen) diff --git a/TableProTests/Core/MongoDB/MongoShellParserTests.swift b/TableProTests/Core/MongoDB/MongoShellParserTests.swift index 3c1c0bf07..1635a5d80 100644 --- a/TableProTests/Core/MongoDB/MongoShellParserTests.swift +++ b/TableProTests/Core/MongoDB/MongoShellParserTests.swift @@ -753,6 +753,166 @@ struct MongoShellParserTests { } } + @Test("bracket notation with backticks") + func testBracketNotationBackticks() throws { + let op = try MongoShellParser.parse("db[`my.collection`].find({})") + if case .find(let collection, let filter, _) = op { + #expect(collection == "my.collection") + #expect(filter == "{}") + } else { + Issue.record("Expected .find operation") + } + } + + // MARK: - getCollection Accessor + + @Test("getCollection with find returns the collection") + func testGetCollectionFind() throws { + let op = try MongoShellParser.parse("db.getCollection(\"users\").find({})") + if case .find(let collection, let filter, _) = op { + #expect(collection == "users") + #expect(filter == "{}") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with single quotes") + func testGetCollectionSingleQuotes() throws { + let op = try MongoShellParser.parse("db.getCollection('users').find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "users") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with backticks") + func testGetCollectionBackticks() throws { + let op = try MongoShellParser.parse("db.getCollection(`users`).find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "users") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection bare reference treated as find all") + func testGetCollectionBareReference() throws { + let op = try MongoShellParser.parse("db.getCollection(\"users\")") + if case .find(let collection, let filter, let options) = op { + #expect(collection == "users") + #expect(filter == "{}") + #expect(options.limit == nil) + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with chained options") + func testGetCollectionChainedOptions() throws { + let op = try MongoShellParser.parse( + "db.getCollection(\"users\").find({\"active\": true}).sort({\"name\": 1}).skip(5).limit(10)" + ) + if case .find(let collection, let filter, let options) = op { + #expect(collection == "users") + #expect(filter == "{\"active\": true}") + #expect(options.sort == "{\"name\": 1}") + #expect(options.skip == 5) + #expect(options.limit == 10) + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection keeps a dotted name as one collection") + func testGetCollectionDottedName() throws { + let op = try MongoShellParser.parse("db.getCollection(\"logs.2024.06\").find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "logs.2024.06") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection reaches a name that collides with a database method") + func testGetCollectionNameCollidingWithDbMethod() throws { + let op = try MongoShellParser.parse("db.getCollection(\"stats\").find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "stats") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with an escaped quote in the name") + func testGetCollectionEscapedQuote() throws { + let op = try MongoShellParser.parse("db.getCollection(\"say\\\"hi\").find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "say\"hi") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with a write method") + func testGetCollectionDeleteMany() throws { + let op = try MongoShellParser.parse("db.getCollection(\"users\").deleteMany({\"active\": false})") + if case .deleteMany(let collection, let filter) = op { + #expect(collection == "users") + #expect(filter == "{\"active\": false}") + } else { + Issue.record("Expected .deleteMany operation") + } + } + + @Test("getCollection tolerates whitespace before the argument list") + func testGetCollectionWhitespaceBeforeParen() throws { + let op = try MongoShellParser.parse("db.getCollection (\"users\").find({})") + if case .find(let collection, _, _) = op { + #expect(collection == "users") + } else { + Issue.record("Expected .find operation") + } + } + + @Test("getCollection with no argument throws") + func testGetCollectionNoArgument() { + #expect(throws: MongoShellParseError.self) { + _ = try MongoShellParser.parse("db.getCollection().find({})") + } + } + + @Test("getCollection with an unquoted argument throws") + func testGetCollectionUnquotedArgument() { + #expect(throws: MongoShellParseError.self) { + _ = try MongoShellParser.parse("db.getCollection(users).find({})") + } + } + + @Test("getCollection with an unterminated name throws") + func testGetCollectionUnterminatedName() { + #expect(throws: MongoShellParseError.self) { + _ = try MongoShellParser.parse("db.getCollection(\"users).find({})") + } + } + + @Test("getSiblingDB stays unsupported so queries never run against another database") + func testGetSiblingDBRemainsUnsupported() { + do { + _ = try MongoShellParser.parse("db.getSiblingDB(\"other\").getCollection(\"users\").find({})") + Issue.record("Expected a parse error") + } catch let error as MongoShellParseError { + guard case .unsupportedMethod(let method) = error else { + Issue.record("Expected .unsupportedMethod") + return + } + #expect(method == "getSiblingDB") + } catch { + Issue.record("Expected MongoShellParseError") + } + } + // MARK: - Additional Special Commands @Test("raw JSON command with unquoted key") diff --git a/TableProTests/PluginTestSources/MongoStreamProjection.swift b/TableProTests/PluginTestSources/MongoStreamProjection.swift new file mode 120000 index 000000000..af7550fa1 --- /dev/null +++ b/TableProTests/PluginTestSources/MongoStreamProjection.swift @@ -0,0 +1 @@ +../../Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift \ No newline at end of file diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index 14d564b3c..d9380710f 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -479,6 +479,40 @@ struct MongoDBQueryBuilderTests { #expect(query.contains(".countDocuments({})")) } + // MARK: - Export Query + + @Test("Export query streams the whole collection") + func exportQueryHasNoLimit() { + let query = builder.buildExportQuery(collection: "users") + #expect(query == "db.users.find({})") + } + + @Test("Export query brackets a dotted collection name") + func exportQueryDottedCollection() { + let query = builder.buildExportQuery(collection: "logs.2024.06") + #expect(query == "db[\"logs.2024.06\"].find({})") + } + + @Test("Export query escapes quotes and backslashes in the collection name") + func exportQueryEscapesCollectionName() { + let query = builder.buildExportQuery(collection: "say\"hi\\bye") + #expect(query == "db[\"say\\\"hi\\\\bye\"].find({})") + } + + @Test("Export query parses back to a find on the same collection") + func exportQueryRoundTripsThroughTheParser() throws { + for collection in ["users", "logs.2024.06", "stats", "2024_orders", "say\"hi"] { + let operation = try MongoShellParser.parse(builder.buildExportQuery(collection: collection)) + if case .find(let parsed, let filter, let options) = operation { + #expect(parsed == collection) + #expect(filter == "{}") + #expect(options.limit == nil) + } else { + Issue.record("Expected .find operation for \(collection)") + } + } + } + // MARK: - ObjectId Matching @Test("Equals on an ObjectId value matches both the ObjectId and the string form") diff --git a/TableProTests/Plugins/MongoStreamProjectionTests.swift b/TableProTests/Plugins/MongoStreamProjectionTests.swift new file mode 100644 index 000000000..d57996ae4 --- /dev/null +++ b/TableProTests/Plugins/MongoStreamProjectionTests.swift @@ -0,0 +1,76 @@ +// +// MongoStreamProjectionTests.swift +// TableProTests +// +// Tests for MongoStreamProjection (compiled via symlink from MongoDBDriverPlugin). +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MongoDB Stream Projection") +struct MongoStreamProjectionTests { + private func text(_ value: Any) -> PluginCellValue { + PluginCellValue.fromOptional("\(value)") + } + + @Test("Header carries the announced columns and type names") + func headerCarriesColumns() { + let projection = MongoStreamProjection( + columns: ["_id", "name"], + columnTypeNames: ["ObjectId", "VARCHAR"] + ) + #expect(projection.header.columns == ["_id", "name"]) + #expect(projection.header.columnTypeNames == ["ObjectId", "VARCHAR"]) + } + + @Test("An empty column set falls back to _id") + func emptyColumnsFallBackToId() { + let projection = MongoStreamProjection(columns: [], columnTypeNames: []) + #expect(projection.columns == ["_id"]) + #expect(projection.columnTypeNames == ["VARCHAR"]) + } + + @Test("Missing type names are padded so the header stays balanced") + func missingTypeNamesArePadded() { + let projection = MongoStreamProjection(columns: ["a", "b", "c"], columnTypeNames: ["INTEGER"]) + #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR", "VARCHAR"]) + } + + @Test("A document missing a column yields null in that slot") + func missingFieldBecomesNull() { + let projection = MongoStreamProjection(columns: ["_id", "name", "email"], columnTypeNames: []) + let row = projection.row(for: ["_id": 1, "email": "a@b.c"], convert: text) + #expect(row == [.text("1"), .null, .text("a@b.c")]) + } + + @Test("Fields outside the column plan never widen a row") + func extraFieldsDoNotWidenTheRow() { + let projection = MongoStreamProjection(columns: ["_id", "name"], columnTypeNames: []) + let row = projection.row(for: ["_id": 1, "name": "Ada", "addedLater": true], convert: text) + #expect(row.count == projection.columns.count) + #expect(row == [.text("1"), .text("Ada")]) + } + + @Test("Every row in a heterogeneous batch matches the header width") + func heterogeneousDocumentsStayAligned() { + let documents: [[String: Any]] = [ + ["_id": 1, "name": "Ada"], + ["_id": 2, "nickname": "Grace"], + ["_id": 3] + ] + let projection = MongoStreamProjection(columns: ["_id", "name", "nickname"], columnTypeNames: []) + + for document in documents { + #expect(projection.row(for: document, convert: text).count == projection.header.columns.count) + } + } + + @Test("Column order drives the row, not the document key order") + func columnOrderDrivesTheRow() { + let projection = MongoStreamProjection(columns: ["z", "a"], columnTypeNames: []) + let row = projection.row(for: ["a": "first", "z": "last"], convert: text) + #expect(row == [.text("last"), .text("first")]) + } +} diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 9fda5884d..6fde0ca8c 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -111,6 +111,8 @@ Count documents: db.users.countDocuments({"role": "admin"}) ``` +**Collection references**: Write `db.users`, `db["users"]`, or `db.getCollection("users")`. The bracket and `getCollection` forms take the name exactly as written, so use them for names that contain dots or spaces, start with a digit, or match a database method such as `stats` or `version`. Single quotes and backticks work in place of double quotes. `db.getSiblingDB(...)` is not supported: a query always runs against the database the connection is using. + **Supported methods**: collection-level `find`, `findOne`, `aggregate`, `countDocuments`/`count`, `insertOne`/`insertMany`, `updateOne`/`updateMany`, `replaceOne`, `deleteOne`/`deleteMany`, `findOneAndUpdate`/`findOneAndReplace`/`findOneAndDelete`, `createIndex`, `dropIndex`, `drop`; database-level `getCollectionNames`/`listCollections`, `createCollection`, `dropDatabase`, `version`, `stats`. Anything else goes through `db.runCommand({...})` or `db.adminCommand({...})`. Unlisted shell methods (for example `distinct` or `getUsers`) return an unsupported-method error. ## Troubleshooting