diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a529413e..63a08a9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -212,6 +213,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. @@ -566,6 +577,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. diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift index 907d68cdd..a13a7c854 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift @@ -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" } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift index e3715107e..09992e0d7 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift @@ -49,6 +49,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]] { @@ -357,9 +364,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() @@ -379,22 +384,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() diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 74a428468..b048658a4 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -735,7 +735,8 @@ 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 @@ -743,14 +744,15 @@ final class MongoDBConnection: @unchecked Sendable { 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 @@ -758,10 +760,8 @@ final class MongoDBConnection: @unchecked Sendable { 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 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift b/Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift new file mode 100644 index 000000000..c41ff1fed --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift @@ -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 = ["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" }) + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBJsonLayout.swift b/Plugins/MongoDBDriverPlugin/MongoDBJsonLayout.swift new file mode 100644 index 000000000..31447db4b --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBJsonLayout.swift @@ -0,0 +1,147 @@ +import Foundation + +/// Lays out Extended JSON, and the shell text `MongoDBShellLiteral` writes from it, without parsing +/// it into a dictionary, so every member stays where the server put it. +/// +/// A catalog document read through `JSONSerialization` comes back in hash order and was then +/// written out key-sorted, which reordered a `$sort` stage, a compound index key and a validator's +/// `properties`. Each of those orders means something to the server. +enum MongoDBJsonLayout { + private static let indentUnit = " " + + /// The members as one Extended JSON object on one line, in the spacing libbson uses for its + /// own output. + static func object(_ members: [(key: String, value: String)]) -> String { + line(members.map { "\(MongoScriptJson.jsonString($0.key)) : \($0.value)" }) + } + + /// The members as one object literal of shell source, every one of them a member of the object + /// it builds. + static func shellObject(_ members: [(key: String, value: String)]) -> String { + line(members.map { "\(MongoDBShellText.memberName($0.key)) : \($0.value)" }) + } + + private static func line(_ body: [String]) -> String { + body.isEmpty ? "{ }" : "{ \(body.joined(separator: ", ")) }" + } + + /// The text spread over one line per member and element, nested `depth` levels in, so it can + /// sit inside a statement that is itself indented. A constructor call such as + /// `BinData(4, "...")` stays on one line as it was written, and so do `new Date(...)` and a + /// computed key such as `["__proto__"]`. + static func indented(_ json: String, depth: Int = 0) -> String { + let scalars = Array(json.unicodeScalars) + var output = String.UnicodeScalarView() + var level = depth + var callDepth = 0 + var index = 0 + var inString = false + var escaped = false + + while index < scalars.count { + let scalar = scalars[index] + index += 1 + if inString { + output.append(scalar) + if escaped { + escaped = false + } else if scalar == "\\" { + escaped = true + } else if scalar == "\"" { + inString = false + } + continue + } + if callDepth > 0 || scalar == "(" { + output.append(scalar) + if scalar == "\"" { inString = true } + if scalar == "(" { callDepth += 1 } + if scalar == ")" { callDepth -= 1 } + continue + } + switch scalar { + case "\"": + inString = true + output.append(scalar) + case "{", "[": + if scalar == "[", let closer = computedKeyEnd(scalars, from: index) { + output.append(contentsOf: scalars[(index - 1) ... closer]) + index = closer + 1 + } else if let closer = emptyContainerEnd(scalars, opening: scalar, from: index) { + output.append(scalar) + output.append(scalars[closer]) + index = closer + 1 + } else { + output.append(scalar) + level += 1 + appendLineBreak(to: &output, level: level) + } + case "}", "]": + level -= 1 + appendLineBreak(to: &output, level: level) + output.append(scalar) + case ",": + output.append(scalar) + appendLineBreak(to: &output, level: level) + case ":": + output.append(contentsOf: ": ".unicodeScalars) + case " ", "\t", "\n", "\r": + if let last = output.last, isWordScalar(last), index < scalars.count, isWordScalar(scalars[index]) { + output.append(" ") + } + default: + output.append(scalar) + } + } + return String(output) + } + + private static func emptyContainerEnd( + _ scalars: [Unicode.Scalar], + opening: Unicode.Scalar, + from start: Int + ) -> Int? { + var index = start + while index < scalars.count, [" ", "\t", "\n", "\r"].contains(scalars[index]) { + index += 1 + } + guard index < scalars.count else { return nil } + let closing: Unicode.Scalar = opening == "{" ? "}" : "]" + return scalars[index] == closing ? index : nil + } + + /// The `]` that closes a computed key: one string literal in brackets with a `:` after it, + /// which a JSON array never is. + private static func computedKeyEnd(_ scalars: [Unicode.Scalar], from start: Int) -> Int? { + guard start < scalars.count, scalars[start] == "\"" else { return nil } + var index = start + 1 + var escaped = false + while index < scalars.count { + let scalar = scalars[index] + index += 1 + if escaped { + escaped = false + } else if scalar == "\\" { + escaped = true + } else if scalar == "\"" { + break + } + } + guard index < scalars.count, scalars[index] == "]" else { return nil } + let closer = index + index += 1 + while index < scalars.count, [" ", "\t", "\n", "\r"].contains(scalars[index]) { + index += 1 + } + return index < scalars.count && scalars[index] == ":" ? closer : nil + } + + private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool { + scalar == "_" || scalar == "$" || scalar.properties.isAlphabetic || ("0" ... "9").contains(scalar) + } + + private static func appendLineBreak(to output: inout String.UnicodeScalarView, level: Int) { + output.append("\n") + output.append(contentsOf: String(repeating: indentUnit, count: max(0, level)).unicodeScalars) + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBNamespaceEntry.swift b/Plugins/MongoDBDriverPlugin/MongoDBNamespaceEntry.swift new file mode 100644 index 000000000..0a4852d22 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBNamespaceEntry.swift @@ -0,0 +1,133 @@ +import Foundation + +/// One entry `listCollections` returned: what the namespace is, and the options it was created with. +/// +/// libmongoc's name listing keeps each entry's `name` and drops its `type`, so every view reached +/// the app as a collection and was offered edits, Rename and Truncate that the server refuses with +/// CommandNotSupportedOnView. +struct MongoDBNamespaceEntry { + static let systemPrefix = "system." + + let name: String + let serverType: String + let optionsJson: String? + + init?(json: String) { + let members = MongoScriptJson.members(of: json) + guard let nameJson = members.first(where: { $0.key == "name" })?.value, + let name = MongoScriptJson.decodedString(nameJson) else { + return nil + } + self.name = name + serverType = members.first { $0.key == "type" } + .flatMap { MongoScriptJson.decodedString($0.value) } ?? "collection" + optionsJson = members.first { $0.key == "options" }?.value + } + + var isView: Bool { serverType == "view" } + + var isSystem: Bool { name.hasPrefix(Self.systemPrefix) } + + /// A time-series collection stays a table: it takes finds, inserts and deletes like one, and + /// refuses only in-place updates and a rename, both of which the server reports itself. + var pluginTableType: String { + if isView { return "VIEW" } + if isSystem { return "SYSTEM TABLE" } + return "TABLE" + } + + func option(_ key: String) -> String? { + guard let optionsJson else { return nil } + return MongoScriptJson.member(of: optionsJson, key: key) + } + + func numberOption(_ key: String) -> Int64? { + guard let optionsJson else { return nil } + return MongoScriptJson.number(in: optionsJson, key: key) + } + + /// The statement that creates this view again, collation included. + func createViewStatement() -> String? { + guard let source = viewSource else { return nil } + var arguments = [ + MongoScriptJson.jsonString(name), + MongoScriptJson.jsonString(source), + MongoDBJsonLayout.indented(pipelineLiteral) + ] + if let collation = option("collation") { + let options = MongoDBJsonLayout.shellObject([ + (key: "collation", value: MongoDBShellLiteral.render(MongoDBCollation.portable(collation))) + ]) + arguments.append(MongoDBJsonLayout.indented(options)) + } + return "db.createView(\(arguments.joined(separator: ", ")))" + } + + /// The statement that redefines this view in place. `collMod` keeps the view's collation and + /// refuses to be given one, and `createView` on a name that exists fails with NamespaceExists. + func collModStatement() -> String? { + guard let source = viewSource else { return nil } + let command = MongoDBJsonLayout.shellObject([ + (key: "collMod", value: MongoScriptJson.jsonString(name)), + (key: "viewOn", value: MongoScriptJson.jsonString(source)), + (key: "pipeline", value: pipelineLiteral) + ]) + return "db.runCommand(\(MongoDBJsonLayout.indented(command)))" + } + + private var pipelineLiteral: String { + MongoDBShellLiteral.render(option("pipeline") ?? "[]") + } + + private var viewSource: String? { + guard isView else { return nil } + return option("viewOn").flatMap(MongoScriptJson.decodedString) + } +} + +/// The shell text Show DDL, Copy DDL and the Structure tab's DDL show for one namespace. +/// +/// A view's header reads `// View:` so MQL export, which appends whatever follows a +/// `// Collection:` line after a collection's documents, never writes a `createView` after the +/// documents it exported from that view. +enum MongoDBNamespaceDDL { + static func text(name: String, entry: MongoDBNamespaceEntry?, indexes: [MongoDBIndexEntry]) -> String { + if let entry, entry.isView { + return [MongoDBShellText.comment("View: \(name)"), entry.createViewStatement()] + .compactMap { $0 } + .joined(separator: "\n") + } + + var sections = [MongoDBShellText.comment("Collection: \(name)")] + if let entry { + sections += optionSections(of: entry) + } + let statements = indexes.filter { !$0.isPrimary }.map { $0.createIndexStatement(collection: name) } + if !statements.isEmpty { + sections.append("\n" + MongoDBShellText.comment("Indexes")) + sections += statements + } + return sections.joined(separator: "\n") + } + + private static func optionSections(of entry: MongoDBNamespaceEntry) -> [String] { + var sections: [String] = [] + if entry.option("capped") == "true" { + var line = "Capped: true, size: \(entry.numberOption("size") ?? 0)" + if let max = entry.numberOption("max") { line += ", max: \(max)" } + sections.append(MongoDBShellText.comment(line)) + } + if let timeSeries = entry.option("timeseries") { + sections.append(MongoDBShellText.comment("Time series: \(MongoDBShellLiteral.render(timeSeries))")) + } + if let validator = entry.option("validator") { + let command = MongoDBJsonLayout.shellObject([ + (key: "collMod", value: MongoScriptJson.jsonString(entry.name)), + (key: "validator", value: MongoDBShellLiteral.render(validator)) + ]) + let statement = "db.runCommand(\(MongoDBJsonLayout.indented(command)))" + sections.append("\n" + MongoDBShellText.comment("Validator") + "\n" + statement) + } + return sections + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBObjectStatements.swift b/Plugins/MongoDBDriverPlugin/MongoDBObjectStatements.swift new file mode 100644 index 000000000..d1aa67943 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBObjectStatements.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The statements Drop, Truncate and Edit View Definition write for one collection or view, each +/// naming it as a string literal the plugin's own escaper wrote. +/// +/// Each used to escape the name by hand. The view template escaped the quote alone, so a +/// backslash just before a quote ended the string and the rest of the name ran as statements. The +/// others escaped one `Character` at a time, which reads a carriage return and line feed as a +/// single line feed, so Drop on `a\r\nb` dropped `a\nb`. +enum MongoDBObjectStatements { + static func drop(_ name: String) -> String { + "\(MongoDBShellText.namedCollection(name)).drop()" + } + + /// `deleteMany({})` empties the collection and leaves it, its indexes and its options in place, + /// which is what Truncate means. `drop()` would take all three. + static func truncate(_ name: String) -> String { + "\(MongoDBShellText.namedCollection(name)).deleteMany({})" + } + + /// What Edit View Definition opens when the view's own definition could not be read. + static func redefineViewTemplate(_ name: String) -> String { + let command = MongoDBJsonLayout.shellObject([ + (key: "collMod", value: MongoScriptJson.jsonString(name)), + (key: "viewOn", value: MongoScriptJson.jsonString("source_collection")), + (key: "pipeline", value: "[ { \"$match\" : { } } ]") + ]) + return "db.runCommand(\(command))" + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 3a6ae2fd6..396caf569 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -98,6 +98,7 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let editorLanguage: EditorLanguage = .javascript static let supportsForeignKeys = false static let supportsSchemaEditing = false + static let supportsRenameView = false static let systemDatabaseNames: [String] = ["admin", "local", "config"] static let tableEntityName = "Collections" static let supportsForeignKeyDisable = false diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 55c3202dd..fd7b5daf8 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -6,7 +6,6 @@ import Foundation import os import TableProLogRedaction -import TableProNumberFormatting import TableProPluginKit final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { @@ -304,9 +303,10 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw MongoDBPluginError.notConnected } - let collections = try await conn.listCollections(database: currentDb) - return collections.sorted(by: { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }) - .map { PluginTableInfo(name: $0, type: "table", rowCount: nil) } + let entries = try await conn.listNamespaces(database: currentDb, named: nil) + .compactMap(MongoDBNamespaceEntry.init(json:)) + return entries.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) + .map { PluginTableInfo(name: $0.name, type: $0.pluginTableType, rowCount: nil) } } @@ -435,19 +435,18 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw MongoDBPluginError.notConnected } - let indexes = try await conn.listIndexes(database: currentDb, collection: table) - - return indexes.compactMap { indexDoc -> PluginIndexInfo? in - guard let name = indexDoc["name"] as? String, - let key = indexDoc["key"] as? [String: Any] else { return nil } - - let columns = Array(key.keys) - let isUnique = (indexDoc["unique"] as? Bool) ?? (name == "_id_") - let isPrimary = name == "_id_" + return try await indexEntries(of: table, conn: conn).map(\.pluginIndexInfo) + } - return PluginIndexInfo( - name: name, columns: columns, isUnique: isUnique, isPrimary: isPrimary, type: "BTREE" - ) + /// A view has no indexes, and the server says so by refusing `listIndexes` with + /// CommandNotSupportedOnView. libmongoc answers a missing collection the same way, with an + /// empty list, which is what the Enumerate Indexes spec asks for. + private func indexEntries(of collection: String, conn: MongoDBConnection) async throws -> [MongoDBIndexEntry] { + do { + return try await conn.listIndexes(database: currentDb, collection: collection) + .compactMap(MongoDBIndexEntry.init(json:)) + } catch let error as MongoDBError where error.code == MongoDBServerErrorCode.commandNotSupportedOnView { + return [] } } @@ -506,68 +505,44 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw MongoDBPluginError.notConnected } - let db = currentDb - var sections: [String] = ["// Collection: \(table)"] - + let entry: MongoDBNamespaceEntry? do { - let result = try await conn.runCommand( - "{\"listCollections\": 1, \"filter\": {\"name\": \"\(escapeJsonString(table))\"}}", - database: db - ) - if let firstDoc = result.first, - let cursor = firstDoc["cursor"] as? [String: Any], - let firstBatch = cursor["firstBatch"] as? [[String: Any]], - let collInfo = firstBatch.first, - let options = collInfo["options"] as? [String: Any] { - if let capped = options["capped"] as? Bool, capped { - let size = options["size"] as? Int ?? 0 - let max = options["max"] as? Int - var cappedInfo = "// Capped: true, size: \(size)" - if let max { cappedInfo += ", max: \(max)" } - sections.append(cappedInfo) - } - if let validator = options["validator"] { - let json = prettyJson(validator) - sections.append( - "\n// Validator\ndb.runCommand({\n \"collMod\": \"\(table)\",\n \"validator\": \(json)\n})" - ) - } - } + entry = try await namespaceEntry(named: table, conn: conn) } catch { Self.logger.debug("Failed to fetch collection info for \(table): \(error.localizedDescription)") + entry = nil + } + if let entry, entry.isView { + return MongoDBNamespaceDDL.text(name: table, entry: entry, indexes: []) } + let indexes: [MongoDBIndexEntry] do { - let indexes = try await conn.listIndexes(database: db, collection: table) - let customIndexes = indexes.filter { ($0["name"] as? String) != "_id_" } - - if !customIndexes.isEmpty { - sections.append("\n// Indexes") - for indexDoc in customIndexes { - guard let name = indexDoc["name"] as? String, - let key = indexDoc["key"] as? [String: Any] else { continue } - - let keyJson = prettyJson(key) - var opts: [String] = [] - if (indexDoc["unique"] as? Bool) == true { opts.append("\"unique\": true") } - if let ttl = indexDoc["expireAfterSeconds"] as? Int { opts.append("\"expireAfterSeconds\": \(ttl)") } - if (indexDoc["sparse"] as? Bool) == true { opts.append("\"sparse\": true") } - opts.append("\"name\": \"\(name)\"") - - let optsJson = "{\(opts.joined(separator: ", "))}" - let accessor = MongoCollectionAccessor.expression(for: table) - sections.append("\(accessor).createIndex(\(keyJson), \(optsJson))") - } - } + indexes = try await indexEntries(of: table, conn: conn) } catch { Self.logger.debug("Failed to fetch indexes for \(table): \(error.localizedDescription)") + indexes = [] } - - return sections.joined(separator: "\n") + return MongoDBNamespaceDDL.text(name: table, entry: entry, indexes: indexes) } + /// The `collMod` that redefines the view in place, which is what Edit View Definition runs. The + /// statement that creates it is in `fetchTableDDL`, where Show DDL and Copy DDL read it. func fetchViewDefinition(view: String, schema: String?) async throws -> String { - throw MongoDBPluginError.unsupportedOperation + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + guard let statement = try await namespaceEntry(named: view, conn: conn)?.collModStatement() else { + throw MongoDBPluginError.viewNotFound(view) + } + return statement + } + + private func namespaceEntry(named name: String, conn: MongoDBConnection) async throws -> MongoDBNamespaceEntry? { + try await conn.listNamespaces(database: currentDb, named: name) + .lazy + .compactMap(MongoDBNamespaceEntry.init(json:)) + .first { $0.name == name } } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { @@ -698,13 +673,11 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The app-level fallback would emit `DROP TABLE `, which the Mongo shell parser rejects. /// Mongo has no schemas or cascade, so both are ignored. func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { - "db.getCollection(\"\(escapeJsonString(name))\").drop()" + MongoDBObjectStatements.drop(name) } - /// `deleteMany({})` empties the collection and leaves it, its indexes and its options in place, - /// which is what Truncate means. `drop()` would take all three. func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { - ["db.getCollection(\"\(escapeJsonString(table))\").deleteMany({})"] + [MongoDBObjectStatements.truncate(table)] } // MARK: - Collection Creation @@ -750,8 +723,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func editViewFallbackTemplate(viewName: String) -> String? { - let escaped = viewName.replacingOccurrences(of: "\"", with: "\\\"") - return "db.runCommand({\"collMod\": \"\(escaped)\", \"viewOn\": \"source_collection\", \"pipeline\": [{\"$match\": {}}]})" + MongoDBObjectStatements.redefineViewTemplate(viewName) } // MARK: - Query Building @@ -991,16 +963,6 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result } - private func prettyJson(_ value: Any) -> String { - let sanitized = BsonDocumentFlattener.sanitizeForJson(value, representation: uuidRepresentation) - guard let json = NumberText.json( - from: sanitized, prettyPrinted: true, preservesFloatingPointForm: true - ) else { - return String(describing: value) - } - return json - } - private func rememberColumnKinds(_ kinds: [BsonValueKind], for columns: [String], collection: String) { guard !collection.isEmpty else { return } var byName: [String: BsonValueKind] = [:] @@ -1091,14 +1053,14 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { enum MongoDBPluginError: Error { case notConnected - case unsupportedOperation + case viewNotFound(String) } extension MongoDBPluginError: PluginDriverError { var pluginErrorMessage: String { switch self { case .notConnected: return String(localized: "Not connected to MongoDB") - case .unsupportedOperation: return String(localized: "Operation not supported for MongoDB") + case .viewNotFound(let name): return String(format: String(localized: "No view named %@ in this database"), name) } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBShellLiteral.swift b/Plugins/MongoDBDriverPlugin/MongoDBShellLiteral.swift new file mode 100644 index 000000000..f94a9e491 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBShellLiteral.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Shell source for a canonical Extended JSON value, which TablePro's shell and mongosh both read +/// back as the BSON the server holds. +/// +/// A bare number in a script is whatever the shell makes of a JavaScript `Number`, so relaxed +/// Extended JSON cannot carry a type: `NumberLong(1)` came back an Int32, a whole Double came back +/// an Int32, and `9007199254740993` came back `9007199254740992`. Every value a bare number cannot +/// carry is written through the constructor that names its type in both shells. A canonical +/// wrapper written as an object reaches mongosh as a document, so a regular expression in a +/// `$match` became an unknown `$regularExpression` operator there. Only a value mongosh has no way +/// to write at all, a DBPointer, `undefined` or a date past JavaScript's range, keeps its wrapper, +/// which TablePro's shell sends to the server as it is. +enum MongoDBShellLiteral { + private static let millisecondsPerDay: Int64 = 86_400_000 + private static let javaScriptDateLimit: Int64 = 8_640_000_000_000_000 + private static let decimalPattern = #"^([+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?|NaN|-?Infinity)$"# + private static let jsonNumberPattern = #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# + + static func render(_ canonicalJson: String) -> String { + let trimmed = canonicalJson.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("[") { + let elements = MongoScriptJson.topLevelElements(trimmed).map(render) + return elements.isEmpty ? "[ ]" : "[ \(elements.joined(separator: ", ")) ]" + } + guard trimmed.hasPrefix("{") else { return scalar(trimmed) } + let members = MongoScriptJson.members(of: trimmed) + return typedValue(members) + ?? MongoDBJsonLayout.shellObject(members.map { (key: $0.key, value: render($0.value)) }) + } + + /// A string is written again through the plugin's own escaper, because the server's text can + /// hold a line separator that libbson leaves raw. Anything that is not a string, a number, + /// `true`, `false` or `null` is written as the string it spells, so no text reaches the + /// statement outside a literal. + private static func scalar(_ token: String) -> String { + if token.hasPrefix("\"") { + return MongoScriptJson.jsonString(MongoScriptJson.decodedString(token) ?? token) + } + let isLiteral = ["true", "false", "null"].contains(token) + || token.range(of: jsonNumberPattern, options: .regularExpression) != nil + return isLiteral ? token : MongoScriptJson.jsonString(token) + } + + private static func typedValue(_ members: [(key: String, value: String)]) -> String? { + guard let first = members.first else { return nil } + if members.count == 2, first.key == "$code", members[1].key == "$scope" { + return code(first.value, scope: members[1].value) + } + guard members.count == 1 else { return nil } + let text = MongoScriptJson.decodedString(first.value) + switch first.key { + case "$numberInt": return text.flatMap { Int32($0) }.map { String($0) } + case "$numberLong": return text.flatMap { Int64($0) }.map { "NumberLong(\"\($0)\")" } + case "$numberDouble": return text.flatMap(double) + case "$numberDecimal": + return text.map { decimal($0) ?? wrapper(first.key, MongoScriptJson.jsonString($0)) } + case "$oid": return text.map { "ObjectId(\(MongoScriptJson.jsonString($0)))" } + case "$date": return millis(first.value).map(date) + case "$binary": return binary(first.value) + case "$timestamp": return timestamp(first.value) + case "$regularExpression": return regularExpression(first.value) + case "$symbol": return text.map { "BSONSymbol(\(MongoScriptJson.jsonString($0)))" } + case "$minKey": return "MinKey()" + case "$maxKey": return "MaxKey()" + case "$code": return code(first.value, scope: nil) + default: return nil + } + } + + /// A fraction reads back as a Double on its own. A whole value needs `Double(...)`, or the + /// shell stores it as an integer. + private static func double(_ text: String) -> String? { + if ["Infinity", "-Infinity", "NaN"].contains(text) { return text } + guard let value = Double(text), value.isFinite else { return nil } + return value.rounded(.towardZero) == value ? "Double(\(value))" : "\(value)" + } + + private static func decimal(_ text: String) -> String? { + guard text.range(of: decimalPattern, options: .regularExpression) != nil else { return nil } + return "NumberDecimal(\(MongoScriptJson.jsonString(text)))" + } + + /// `ISODate` spells a four-digit year only, in mongosh as well, so any other date is written as + /// the instant it is, as far as a JavaScript `Date` reaches. + private static func date(_ millis: Int64) -> String { + if let text = isoDate(millis) { return "ISODate(\"\(text)\")" } + guard (-javaScriptDateLimit ... javaScriptDateLimit).contains(millis) else { return dateWrapper(millis) } + return "new Date(\(millis))" + } + + private static func regularExpression(_ valueJson: String) -> String? { + guard let pattern = MongoScriptJson.member(of: valueJson, key: "pattern").flatMap(MongoScriptJson.decodedString), + let options = MongoScriptJson.member(of: valueJson, key: "options").flatMap(MongoScriptJson.decodedString) + else { + return nil + } + return "BSONRegExp(\(MongoScriptJson.jsonString(pattern)), \(MongoScriptJson.jsonString(options)))" + } + + private static func millis(_ dateJson: String) -> Int64? { + MongoScriptJson.member(of: dateJson, key: "$numberLong") + .flatMap(MongoScriptJson.decodedString) + .flatMap { Int64($0) } + } + + private static func dateWrapper(_ millis: Int64) -> String { + wrapper("$date", wrapper("$numberLong", MongoScriptJson.jsonString(String(millis)))) + } + + /// A canonical wrapper rebuilt from the value it was read as, never copied from the server's text. + private static func wrapper(_ key: String, _ value: String) -> String { + MongoDBJsonLayout.shellObject([(key: key, value: value)]) + } + + private static func binary(_ valueJson: String) -> String? { + guard let base64 = MongoScriptJson.member(of: valueJson, key: "base64").flatMap(MongoScriptJson.decodedString), + let subtype = MongoScriptJson.member(of: valueJson, key: "subType") + .flatMap(MongoScriptJson.decodedString) + .flatMap({ UInt8($0, radix: 16) }) else { + return nil + } + return "BinData(\(subtype), \(MongoScriptJson.jsonString(base64)))" + } + + private static func timestamp(_ valueJson: String) -> String? { + guard let seconds = MongoScriptJson.member(of: valueJson, key: "t").flatMap({ UInt32($0) }), + let increment = MongoScriptJson.member(of: valueJson, key: "i").flatMap({ UInt32($0) }) else { + return nil + } + return "Timestamp(\(seconds), \(increment))" + } + + private static func code(_ codeJson: String, scope scopeJson: String?) -> String? { + guard let source = MongoScriptJson.decodedString(codeJson) else { return nil } + let arguments = [MongoScriptJson.jsonString(source)] + (scopeJson.map { [render($0)] } ?? []) + return "Code(\(arguments.joined(separator: ", ")))" + } + + /// The instant in the proleptic Gregorian calendar JavaScript's `Date` counts in, for the years + /// 1 through 9999 that a four-digit `ISODate` string can spell. + private static func isoDate(_ millis: Int64) -> String? { + var days = millis / millisecondsPerDay + var dayMillis = millis % millisecondsPerDay + if dayMillis < 0 { + days -= 1 + dayMillis += millisecondsPerDay + } + let civil = civilDate(daysSinceEpoch: days) + guard (1 ... 9_999).contains(civil.year) else { return nil } + return String( + format: "%04lld-%02lld-%02lldT%02lld:%02lld:%02lld.%03lldZ", + civil.year, civil.month, civil.day, + dayMillis / 3_600_000, dayMillis / 60_000 % 60, dayMillis / 1_000 % 60, dayMillis % 1_000 + ) + } + + /// Howard Hinnant's `civil_from_days`, which has no calendar reform in it, unlike Foundation's + /// Gregorian calendar, which switches to the Julian one before October 1582. + private static func civilDate(daysSinceEpoch: Int64) -> (year: Int64, month: Int64, day: Int64) { + let shifted = daysSinceEpoch + 719_468 + let era = (shifted >= 0 ? shifted : shifted - 146_096) / 146_097 + let dayOfEra = shifted - era * 146_097 + let yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let monthIndex = (5 * dayOfYear + 2) / 153 + let day = dayOfYear - (153 * monthIndex + 2) / 5 + 1 + let month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9 + return (year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0), month: month, day: day) + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBShellText.swift b/Plugins/MongoDBDriverPlugin/MongoDBShellText.swift new file mode 100644 index 000000000..cd8356df3 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBShellText.swift @@ -0,0 +1,57 @@ +import Foundation +import TableProPluginKit + +/// The comment, the collection expression and the member name the plugin writes around a name the +/// server chose, each escaped for where it stands. +/// +/// A name reaches these statements from the server, and a person then runs them from an editor +/// tab. A view named with a line feed in it ended its own `// View:` comment, and the text after +/// the line feed ran as a statement of its own. +enum MongoDBShellText { + /// A line comment that ends where the line does. Every character that ends a line for + /// JavaScript or for the editor's statement scanner, and every other control character, is + /// written as its escape. + static func comment(_ text: String) -> String { + var line = "// " + for scalar in text.unicodeScalars { + if let escape = MongoScriptJson.lineBreakingEscape(scalar) { + line.append(escape) + } else { + line.unicodeScalars.append(scalar) + } + } + return line + } + + /// `db.` for a name made only of identifier characters, and `db.getCollection("")` + /// for any other. + /// + /// The test runs one scalar at a time. `Character.isLetter` reads a grapheme's first scalar + /// only, so a test by `Character` passes punctuation that shares a grapheme with a letter. + static func collection(_ name: String) -> String { + guard isIdentifier(name), !MongoCollectionAccessor.isShadowedByDatabaseMember(name) else { + return namedCollection(name) + } + return "db.\(name)" + } + + /// `db.getCollection("")`, which reaches every name. + static func namedCollection(_ name: String) -> String { + "db.getCollection(\(MongoScriptJson.jsonString(name)))" + } + + /// A member name as an object literal key. Written as a plain key, `__proto__` sets the + /// object's prototype and adds no member, so the field never reached the server. A computed + /// key adds it like any other name, in TablePro's shell and in mongosh. + static func memberName(_ name: String) -> String { + let literal = MongoScriptJson.jsonString(name) + return name == "__proto__" ? "[\(literal)]" : literal + } + + private static func isIdentifier(_ name: String) -> Bool { + guard let first = name.unicodeScalars.first, first == "_" || first.properties.isXIDStart else { + return false + } + return name.unicodeScalars.allSatisfy { $0 == "_" || $0.properties.isXIDContinue } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift b/Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift index 74b0e3723..46d5a8816 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift @@ -9,6 +9,7 @@ enum MongoDBServerErrorCode { static let badValue: UInt32 = 2 static let indexNotFound: UInt32 = 27 static let maxTimeMSExpired: UInt32 = 50 + static let commandNotSupportedOnView: UInt32 = 166 static let cursorKilled: UInt32 = 237 } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift index ab29d1361..1d5f546ff 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift @@ -69,20 +69,32 @@ enum MongoScriptCommandBuilder { return "{\(fields.joined(separator: ", "))}" } - static func createIndex(collection: String, keys: String, options: [String: Any]) -> String { - var fields = ["\"key\": \(keys)", "\"name\": \(MongoScriptJson.jsonString(indexName(keys: keys, options: options)))"] - appendPassThrough( - &fields, - options: options, - keys: [ - "unique", "sparse", "expireAfterSeconds", "partialFilterExpression", - "collation", "background", "hidden", "weights", "default_language" - ] - ) - return """ - {"createIndexes": \(MongoScriptJson.jsonString(collection)), \ - "indexes": [{\(fields.joined(separator: ", "))}]} - """ + /// Options that belong to the `createIndexes` command rather than to the index it builds. + static let createIndexesCommandOptions: Set = ["commitQuorum", "comment", "maxTimeMS", "writeConcern"] + + /// Every option the script wrote goes into the index spec as the text it arrived in, in the + /// order it was written. A fixed list of names dropped `wildcardProjection`, a 2d index's + /// `bits`, `min` and `max`, and a text index's `language_override`, which builds a different + /// index than the one asked for, and rebuilding the values through a dictionary reordered a + /// partial filter's members. + static func createIndex(collection: String, keys: String, optionsJson: String?) -> String { + let name = indexName(keys: keys, optionsJson: optionsJson) + var spec = ["\"key\": \(keys)", "\"name\": \(MongoScriptJson.jsonString(name))"] + var commandFields: [String] = [] + let options = optionsJson.map { MongoScriptJson.members(of: $0) } ?? [] + for option in options where option.key != "key" && option.key != "name" && option.value != "null" { + let field = "\(MongoScriptJson.jsonString(option.key)): \(option.value)" + if createIndexesCommandOptions.contains(option.key) { + commandFields.append(field) + } else { + spec.append(field) + } + } + let command = [ + "\"createIndexes\": \(MongoScriptJson.jsonString(collection))", + "\"indexes\": [{\(spec.joined(separator: ", "))}]" + ] + commandFields + return "{\(command.joined(separator: ", "))}" } static func find( @@ -164,8 +176,11 @@ enum MongoScriptCommandBuilder { /// The name MongoDB gives an index the script did not name, which is the key names and their /// directions joined with underscores, in the order the key document declares them. - static func indexName(keys: String, options: [String: Any]) -> String { - if let named = options["name"] as? String, !named.isEmpty { return named } + static func indexName(keys: String, optionsJson: String?) -> String { + let named = optionsJson + .flatMap { MongoScriptJson.member(of: $0, key: "name") } + .flatMap(MongoScriptJson.decodedString) + if let named, !named.isEmpty { return named } let parts = MongoScriptJson.members(of: keys).map { member -> String in "\(member.key)_\(direction(of: member.value))" } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift index 637f5bc8c..0911ad853 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift @@ -491,7 +491,7 @@ final class MongoScriptHost { let statement = MongoScriptCommandBuilder.createIndex( collection: collectionName(request), keys: MongoScriptJson.rawJson(request["keys"]) ?? "{}", - options: MongoScriptJson.options(request["options"]) + optionsJson: MongoScriptJson.rawJson(request["options"]) ) return try command(statement, request) } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift b/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift index 781a98311..a1195581f 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift @@ -16,22 +16,19 @@ enum MongoScriptJson { "{\"ok\":false,\"e\":{\"m\":\(jsonString(message)),\"c\":\(code)}}" } + /// A JSON string literal that is also a JavaScript one on a single line, so a statement written + /// around it for the editor splits where the shell does. static func jsonString(_ value: String) -> String { - var escaped = "" - escaped.reserveCapacity(value.count + 2) - escaped.append("\"") - for character in value.unicodeScalars { - switch character { + var escaped = "\"" + for scalar in value.unicodeScalars { + switch scalar { case "\"": escaped.append("\\\"") case "\\": escaped.append("\\\\") - case "\n": escaped.append("\\n") - case "\r": escaped.append("\\r") - case "\t": escaped.append("\\t") default: - if character.value < 0x20 { - escaped.append(String(format: "\\u%04x", character.value)) + if let escape = lineBreakingEscape(scalar) { + escaped.append(escape) } else { - escaped.unicodeScalars.append(character) + escaped.unicodeScalars.append(scalar) } } } @@ -39,6 +36,33 @@ enum MongoScriptJson { return escaped } + /// The escape for a character that ends a line or cannot be seen, or nil for any other. + /// + /// JSON only requires the C0 controls to be escaped. JavaScript also ends a line at U+2028 and + /// U+2029, and the editor's statement scanner ends one wherever `Character.isNewline` does, + /// which adds U+0085. Written raw, any of them ends a `//` comment early, and the scanner ends a + /// string literal there while the shell keeps reading it. + static func lineBreakingEscape(_ scalar: Unicode.Scalar) -> String? { + switch scalar { + case "\n": return "\\n" + case "\r": return "\\r" + case "\t": return "\\t" + default: + let value = scalar.value + guard value < 0x20 || (0x7F ... 0x9F).contains(value) || value == 0x2028 || value == 0x2029 else { + return nil + } + return String(format: "\\u%04x", value) + } + } + + /// The string a member's value text spells, escapes decoded, or nil when the value is not a string. + static func decodedString(_ valueJson: String) -> String? { + let trimmed = valueJson.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("\"") else { return nil } + return try? JSONDecoder().decode(String.self, from: Data(trimmed.utf8)) + } + /// Whether this object is an Extended JSON wrapper around a single BSON value. /// /// `db.users.distinct("_id")` answers with ObjectIds, whose Extended JSON is `{"$oid": …}`. diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift b/Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift index 3fe02d173..799d0edf4 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift @@ -32,6 +32,16 @@ enum MongoScriptPrelude { })(); function __ejson(value) { return JSON.stringify(EJSON.serialize(value)); } + + // Assigning a member named `__proto__` sets the object's prototype instead of adding the + // member, so the field was never sent and never read. That one name is defined instead. + function __setMember(target, key, value) { + if (key === "__proto__") { + Object.defineProperty(target, key, { value: value, enumerable: true, writable: true, configurable: true }); + } else { + target[key] = value; + } + } """ private static let values = """ @@ -81,7 +91,7 @@ enum MongoScriptPrelude { function NumberDecimal(value) { if (!(this instanceof NumberDecimal)) { return new NumberDecimal(value); } var text = value === undefined ? "0" : String(value); - if (!/^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?$/.test(text)) { + if (!/^([+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?|NaN|-?Infinity)$/.test(text)) { throw new Error("NumberDecimal takes a number"); } this.__value = text; @@ -89,6 +99,22 @@ enum MongoScriptPrelude { NumberDecimal.prototype.toString = function () { return this.__value; }; NumberDecimal.prototype.toEJSON = function () { return { "$numberDecimal": this.__value }; }; + // A whole JavaScript number is sent as an integer, so a Double that happens to be whole, 1.0 or + // -0.0, needs its own constructor to stay a Double. Show DDL writes one for every such value. + function __doubleText(value) { + return value === 0 && 1 / value < 0 ? "-0.0" : String(value); + } + + function Double(value) { + if (!(this instanceof Double)) { return new Double(value); } + var number = value === undefined ? 0 : Number(value); + if (isNaN(number) && String(value) !== "NaN") { throw new Error("Double takes a number"); } + this.__value = number; + } + Double.prototype.toString = function () { return __doubleText(this.__value); }; + Double.prototype.valueOf = function () { return this.__value; }; + Double.prototype.toEJSON = function () { return { "$numberDouble": __doubleText(this.__value) }; }; + function Timestamp(t, i) { if (!(this instanceof Timestamp)) { return new Timestamp(t, i); } this.t = t === undefined ? 0 : t; @@ -97,6 +123,32 @@ enum MongoScriptPrelude { Timestamp.prototype.toString = function () { return "Timestamp(" + this.t + ", " + this.i + ")"; }; Timestamp.prototype.toEJSON = function () { return { "$timestamp": { t: this.t, i: this.i } }; }; + function BSONRegExp(pattern, options) { + if (!(this instanceof BSONRegExp)) { return new BSONRegExp(pattern, options); } + if (typeof pattern !== "string") { throw new Error("BSONRegExp takes its pattern as a string"); } + if (pattern.indexOf("\\u0000") !== -1) { throw new Error("A BSONRegExp pattern cannot hold a null character"); } + var flags = options === undefined || options === null ? "" : options; + if (typeof flags !== "string" || !/^[ilmsux]*$/.test(flags)) { + throw new Error("BSONRegExp takes options from i, l, m, s, u and x"); + } + this.pattern = pattern; + this.options = flags.split("").sort().join(""); + } + BSONRegExp.prototype.toString = function () { + return "BSONRegExp(" + JSON.stringify(this.pattern) + ", " + JSON.stringify(this.options) + ")"; + }; + BSONRegExp.prototype.toEJSON = function () { + return { "$regularExpression": { pattern: this.pattern, options: this.options } }; + }; + + function BSONSymbol(value) { + if (!(this instanceof BSONSymbol)) { return new BSONSymbol(value); } + this.value = String(value); + } + BSONSymbol.prototype.toString = function () { return "BSONSymbol(" + JSON.stringify(this.value) + ")"; }; + BSONSymbol.prototype.valueOf = function () { return this.value; }; + BSONSymbol.prototype.toEJSON = function () { return { "$symbol": this.value }; }; + function __subTypeHex(subtype) { var hex = subtype.toString(16); return hex.length === 1 ? "0" + hex : hex; @@ -552,7 +604,16 @@ enum MongoScriptPrelude { var command = { create: String(name) }; if (options) { for (var key in options) { - if (Object.prototype.hasOwnProperty.call(options, key)) { command[key] = options[key]; } + if (Object.prototype.hasOwnProperty.call(options, key)) { __setMember(command, key, options[key]); } + } + } + return this.runCommand(command); + }; + DB.prototype.createView = function (name, source, pipeline, options) { + var command = { create: String(name), viewOn: String(source), pipeline: pipeline || [] }; + if (options) { + for (var key in options) { + if (Object.prototype.hasOwnProperty.call(options, key)) { __setMember(command, key, options[key]); } } } return this.runCommand(command); @@ -603,7 +664,7 @@ enum MongoScriptPrelude { } var document = {}; for (var key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { document[key] = serialize(value[key]); } + if (Object.prototype.hasOwnProperty.call(value, key)) { __setMember(document, key, serialize(value[key])); } } return document; } @@ -630,7 +691,7 @@ enum MongoScriptPrelude { } var document = {}; for (var key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { document[key] = deserialize(value[key]); } + if (Object.prototype.hasOwnProperty.call(value, key)) { __setMember(document, key, deserialize(value[key])); } } return document; } diff --git a/TablePro/Extensions/EditorLanguage+TreeSitter.swift b/TablePro/Extensions/EditorLanguage+TreeSitter.swift index 69581f9a5..d5fb7e297 100644 --- a/TablePro/Extensions/EditorLanguage+TreeSitter.swift +++ b/TablePro/Extensions/EditorLanguage+TreeSitter.swift @@ -16,6 +16,12 @@ extension EditorLanguage { } } + /// What starts a line comment in a query tab of this language, or an empty string when the + /// language has none. + var lineCommentMarker: String { + treeSitterLanguage.lineCommentString + } + var codeBlockTag: String { switch self { case .sql: return "sql" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 3b7b62139..f832074da 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -181584,6 +181584,9 @@ }, "No view definition found for %@." : { + }, + "No view named %@ in this database" : { + }, "This table requires a partition filter. Add a WHERE clause on the partition column." : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 4139116b1..0af22ec02 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -160,7 +160,9 @@ extension MainContentCoordinator { query = Self.viewDefinitionFallback( viewName: viewName, error: error, - driver: DatabaseManager.shared.driver(for: self.connection.id) + template: DatabaseManager.shared.driver(for: self.connection.id)? + .editViewFallbackTemplate(viewName: viewName), + lineComment: self.services.pluginManager.editorLanguage(for: self.connection.type).lineCommentMarker ) } WindowManager.shared.openTab(payload: EditorTabPayload( @@ -173,17 +175,17 @@ extension MainContentCoordinator { } } - /// Every line of the error is commented out. A driver error can span several lines, and only the - /// first used to be, so the rest landed in the query tab as SQL. - static func viewDefinitionFallback(viewName: String, error: Error, driver: DatabaseDriver?) -> String { - let template = driver?.editViewFallbackTemplate(viewName: viewName) - ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" - let reason = error.localizedDescription - .split(separator: "\n", omittingEmptySubsequences: false) - .map { "-- \($0)" } - .joined(separator: "\n") - let heading = "-- " + String(localized: "Could not fetch the view definition:") - return "\(heading)\n\(reason)\n\(template)" + /// Every line of the error is commented out in the tab's own language, and a line ends wherever + /// the engine or the editor's scanner ends one. A MongoDB tab runs JavaScript, where `--` is the + /// decrement operator, and an error naming a view with a carriage return in it ended the comment + /// partway through the name. A language with no line comment gets the template alone. + static func viewDefinitionFallback(viewName: String, error: Error, template: String?, lineComment: String) -> String { + let template = template ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" + guard !lineComment.isEmpty else { return template } + let reason = error.localizedDescription.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + let comments = ([String(localized: "Could not fetch the view definition:")] + reason.map(String.init)) + .map { "\(lineComment) \($0)" } + return (comments + [template]).joined(separator: "\n") } // MARK: - Export/Import diff --git a/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift b/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift new file mode 100644 index 000000000..fb959b5fe --- /dev/null +++ b/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift @@ -0,0 +1,218 @@ +// +// MongoDBGeneratedDDLContainmentTests.swift +// TableProTests +// +// Catalog fixtures spell each name the way libbson writes it into canonical Extended JSON: the +// quote, the backslash and the C0 controls escaped, and every other character raw, U+2028 and +// U+2029 included. +// + +import Foundation +import JavaScriptCore +import Testing + +@testable import TablePro + +/// Runs the DDL Show DDL and Edit View Definition write for a namespace whose name the server +/// chose, split by the editor's statement scanner and evaluated by the real prelude one statement +/// at a time, as a query tab runs it. Nothing but the statements the DDL is for may reach the host. +struct MongoDBGeneratedDDLContainmentTests { + private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost + + private static let bookkeeping: Set = [ + "currentDatabase", "cursorConfigure", "cursorClose", "useDatabase", "sleep", "newObjectId" + ] + + private static let hostileNames = [ + "v\ndb.probe.drop()", + "v\rdb.probe.drop()", + "v\r\ndb.probe.drop()", + "v\u{2028}db.probe.drop()", + "v\u{2029}db.probe.drop()", + "v\u{85}db.probe.drop()", + "v\u{0B}db.probe.drop()", + "v */ db.probe.drop() /* ", + "v\"); db.probe.drop(); (\"", + "v'); db.probe.drop(); ('" + ] + + /// Names that also break an escape made by hand: a backslash before a quote, which an escape + /// of the quote alone turns into an escaped backslash and a closing quote, and the clusters an + /// escape by `Character` misreads. + private static let objectNames = hostileNames + [ + "v\\\"}); db.probe.drop(); //", + "v\\\"); db.probe.drop(); //", + "v\r\nb", + "v\"\u{301}); db.probe.drop(); //", + "v\\\u{301}b" + ] + + private func libbson(_ value: String) -> String { + var text = "\"" + for scalar in value.unicodeScalars { + switch scalar { + case "\"": text += "\\\"" + case "\\": text += "\\\\" + default: + if scalar.value < 0x20 { + text += String(format: "\\u%04x", scalar.value) + } else { + text.unicodeScalars.append(scalar) + } + } + } + return text + "\"" + } + + private func view(named name: String) throws -> MongoDBNamespaceEntry { + try #require(MongoDBNamespaceEntry(json: """ + { "name" : \(libbson(name)), "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \ + [ { "$match" : { "tag" : \(libbson(name)) } } ] }, "info" : { "readOnly" : true } } + """)) + } + + private func collection(named name: String) throws -> MongoDBNamespaceEntry { + try #require(MongoDBNamespaceEntry(json: """ + { "name" : \(libbson(name)), "type" : "collection", "options" : { "timeseries" : \ + { "timeField" : \(libbson(name)), "granularity" : "hours" }, "validator" : \ + { \(libbson(name)) : { "$type" : "string" } } } } + """)) + } + + private func index(named name: String) throws -> MongoDBIndexEntry { + try #require(MongoDBIndexEntry(json: """ + { "v" : { "$numberInt" : "2" }, "key" : { \(libbson(name)) : { "$numberInt" : "1" } }, \ + "name" : \(libbson(name)), "partialFilterExpression" : { "tag" : \(libbson(name)) } } + """)) + } + + private func requests(runningEachStatementOf text: String, replies: [String] = []) throws -> [[String: Any]] { + let host = RecordingHost() + host.replies = replies + let context = try MongoScriptContext.make( + execute: { host.handle($0) }, + emit: { host.record(printed: $0) } + ) + for statement in JavaScriptStatementScanner.executableStatements(in: text) { + context.evaluateScript(statement.text) + #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") + context.exception = nil + } + return host.requests.filter { !Self.bookkeeping.contains(($0["op"] as? String) ?? "") } + } + + private func decoded(_ key: String, in json: String) -> String? { + MongoScriptJson.member(of: json, key: key).flatMap(MongoScriptJson.decodedString) + } + + /// Whether the only character in the text that ends a line, for JavaScript or for the editor's + /// scanner, or that is a control character, is the line feed the layout put between lines. + private func onlyLayoutLineFeeds(_ text: String) -> Bool { + !text.unicodeScalars.contains { scalar in + scalar != "\n" && (Character(scalar).isNewline || scalar.properties.generalCategory == .control) + } + } + + @Test("A view's DDL creates that view and runs nothing its name holds") + func viewDDLRunsOnlyCreateView() throws { + for name in Self.hostileNames { + let text = MongoDBNamespaceDDL.text(name: name, entry: try view(named: name), indexes: []) + let header = try #require(text.components(separatedBy: "\n").first) + + #expect(header.hasPrefix("// View: v"), "\(name.debugDescription)") + #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text) + #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") + let command = try #require(sent.first?["command"] as? String) + #expect(decoded("create", in: command) == name) + #expect(decoded("viewOn", in: command) == "src") + let pipeline = try #require(MongoScriptJson.member(of: command, key: "pipeline")) + let stage = try #require(MongoScriptJson.topLevelElements(pipeline).first) + let match = try #require(MongoScriptJson.member(of: stage, key: "$match")) + #expect(decoded("tag", in: match) == name) + } + } + + @Test("Edit View Definition redefines that view and runs nothing its name holds") + func collModRunsOnlyCollMod() throws { + for name in Self.hostileNames { + let text = try #require(try view(named: name).collModStatement()) + #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text) + #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") + let command = try #require(sent.first?["command"] as? String) + #expect(decoded("collMod", in: command) == name) + } + } + + @Test("Drop drops the collection it names and nothing else") + func dropRunsOnlyItsDrop() throws { + for name in Self.objectNames { + let text = MongoDBObjectStatements.drop(name) + #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text) + #expect(sent.map { $0["op"] as? String } == ["dropCollection"], "\(name.debugDescription)") + #expect(sent.first?["collection"] as? String == name, "\(name.debugDescription)") + } + } + + @Test("Truncate empties the collection it names and nothing else") + func truncateRunsOnlyItsDelete() throws { + for name in Self.objectNames { + let text = MongoDBObjectStatements.truncate(name) + #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text, replies: [#"{"n":0}"#]) + #expect(sent.map { $0["op"] as? String } == ["delete"], "\(name.debugDescription)") + #expect(sent.first?["collection"] as? String == name, "\(name.debugDescription)") + #expect(sent.first?["multi"] as? Bool == true, "\(name.debugDescription)") + } + } + + @Test("Edit View Definition's fallback redefines the view it names and runs nothing its name holds") + func fallbackTemplateRunsOnlyCollMod() throws { + for name in Self.objectNames { + let text = MongoDBObjectStatements.redefineViewTemplate(name) + #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text) + #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") + let command = try #require(sent.first?["command"] as? String) + #expect(decoded("collMod", in: command) == name, "\(name.debugDescription)") + #expect(decoded("viewOn", in: command) == "source_collection") + } + } + + @Test("A collection's DDL sets its validator and builds its index, and runs nothing a name or an option holds") + func collectionDDLRunsOnlyItsStatements() throws { + for name in Self.hostileNames { + let text = MongoDBNamespaceDDL.text( + name: name, entry: try collection(named: name), indexes: [try index(named: name)] + ) + let lines = text.components(separatedBy: "\n") + + #expect(lines.first?.hasPrefix("// Collection: v") == true, "\(name.debugDescription)") + #expect(lines.contains { $0.hasPrefix("// Time series: ") }, "\(name.debugDescription)") + #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") + + let sent = try requests(runningEachStatementOf: text) + #expect(sent.map { $0["op"] as? String } == ["command", "createIndex"], "\(name.debugDescription)") + let command = try #require(sent.first?["command"] as? String) + #expect(decoded("collMod", in: command) == name) + let validator = try #require(MongoScriptJson.member(of: command, key: "validator")) + #expect(MongoScriptJson.members(of: validator).map(\.key) == [name]) + + let createIndex = try #require(sent.last) + #expect(createIndex["collection"] as? String == name) + let keys = try #require(createIndex["keys"] as? String) + #expect(MongoScriptJson.members(of: keys).map(\.key) == [name]) + let options = try #require(createIndex["options"] as? String) + #expect(decoded("name", in: options) == name) + let filter = try #require(MongoScriptJson.member(of: options, key: "partialFilterExpression")) + #expect(decoded("tag", in: filter) == name) + } + } +} diff --git a/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift b/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift index 6fcd19fb9..5aa125859 100644 --- a/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift +++ b/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift @@ -222,24 +222,77 @@ struct MongoScriptCommandBuilderTests { @Test("An unnamed index takes the name MongoDB gives it") func indexNaming() { #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"a\":1,\"b\":-1}", options: [:]) == "a_1_b_-1" + MongoScriptCommandBuilder.indexName(keys: "{\"a\":1,\"b\":-1}", optionsJson: nil) == "a_1_b_-1" ) #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"loc\":\"2dsphere\"}", options: [:]) == "loc_2dsphere" + MongoScriptCommandBuilder.indexName(keys: "{\"loc\":\"2dsphere\"}", optionsJson: "{}") == "loc_2dsphere" + ) + #expect( + MongoScriptCommandBuilder.indexName(keys: "{\"a\":1}", optionsJson: "{\"name\":\"custom\"}") == "custom" + ) + #expect( + MongoScriptCommandBuilder.indexName(keys: "{\"a\":1}", optionsJson: "{\"name\":\"a\\\"b\"}") == "a\"b" ) - #expect(MongoScriptCommandBuilder.indexName(keys: "{\"a\":1}", options: ["name": "custom"]) == "custom") } @Test("createIndex passes the options MongoDB accepts") func createIndexOptions() { let command = MongoScriptCommandBuilder.createIndex( - collection: "orders", keys: "{\"a\":1}", options: ["unique": true, "expireAfterSeconds": 60] + collection: "orders", keys: "{\"a\":1}", optionsJson: "{\"unique\": true, \"expireAfterSeconds\": 60}" ) #expect(command.contains("\"createIndexes\": \"orders\"")) #expect(command.contains("\"unique\": true")) #expect(command.contains("\"expireAfterSeconds\": 60")) } + @Test("Every index option reaches the spec as written, in the order written") + func createIndexKeepsEveryOption() throws { + let cases: [(keys: String, options: String, spec: String)] = [ + ( + "{\"$**\":1}", + "{\"wildcardProjection\":{\"zeta\":1,\"alpha\":1}}", + "{\"key\": {\"$**\":1}, \"name\": \"$**_1\", \"wildcardProjection\": {\"zeta\":1,\"alpha\":1}}" + ), + ( + "{\"p\":\"2d\"}", + "{\"bits\":20,\"min\":-500,\"max\":500}", + "{\"key\": {\"p\":\"2d\"}, \"name\": \"p_2d\", \"bits\": 20, \"min\": -500, \"max\": 500}" + ), + ( + "{\"age\":1}", + "{\"partialFilterExpression\":{\"b\":{\"$gt\":1},\"age\":{\"$exists\":true}}}", + "{\"key\": {\"age\":1}, \"name\": \"age_1\", \"partialFilterExpression\": {\"b\":{\"$gt\":1},\"age\":{\"$exists\":true}}}" + ), + ( + "{\"_fts\":\"text\",\"_ftsx\":1}", + "{\"name\":\"t\",\"weights\":{\"b\":2},\"language_override\":\"lang\",\"textIndexVersion\":3}", + "{\"key\": {\"_fts\":\"text\",\"_ftsx\":1}, \"name\": \"t\", \"weights\": {\"b\":2}, \"language_override\": \"lang\", \"textIndexVersion\": 3}" + ) + ] + + for testCase in cases { + let command = MongoScriptCommandBuilder.createIndex( + collection: "people", keys: testCase.keys, optionsJson: testCase.options + ) + #expect(command == "{\"createIndexes\": \"people\", \"indexes\": [\(testCase.spec)]}") + _ = try JSONSerialization.jsonObject(with: Data(command.utf8)) + } + } + + @Test("Options that belong to the createIndexes command are sent there, not in the index spec") + func createIndexCommandOptions() { + let command = MongoScriptCommandBuilder.createIndex( + collection: "people", + keys: "{\"a\":1}", + optionsJson: "{\"maxTimeMS\":5000,\"unique\":true,\"commitQuorum\":\"majority\",\"sparse\":null}" + ) + + #expect(command == """ + {"createIndexes": "people", "indexes": [{"key": {"a":1}, "name": "a_1", "unique": true}], \ + "maxTimeMS": 5000, "commitQuorum": "majority"} + """) + } + @Test("A find for EXPLAIN carries the cursor's own modifiers") func explainFind() throws { var options = MongoScriptCursorOptions.none @@ -294,7 +347,9 @@ struct MongoScriptObjectIdTests { @Test("Two generated ids differ") func uniqueness() { - #expect(MongoScriptObjectId.generate() != MongoScriptObjectId.generate()) + let first = MongoScriptObjectId.generate() + let second = MongoScriptObjectId.generate() + #expect(first != second) } @Test("The leading four bytes are the current time") diff --git a/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift b/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift new file mode 100644 index 000000000..13287fdce --- /dev/null +++ b/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift @@ -0,0 +1,194 @@ +// +// MongoScriptDefinitionRoundTripTests.swift +// TableProTests +// +// The catalog fixtures are canonical Extended JSON as libmongoc 1.28 renders it, read from +// MongoDB 7.0.43. +// + +import Foundation +import JavaScriptCore +import Testing + +/// Runs the text Show DDL and Edit View Definition write through the real prelude, and compares +/// what reaches the host with the catalog entry it was written from, one BSON type at a time. +/// Written as relaxed Extended JSON, that text sent `NumberLong(1)` and a whole Double back as +/// Int32s, `9007199254740993` as `9007199254740992`, and a view holding a MinKey or a Timestamp +/// as a document the server could not read. +struct MongoScriptDefinitionRoundTripTests { + private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost + + private static let pipeline = """ + [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "w" : { "$eq" : { "$numberDouble" : "1.0" } }, \ + "big" : { "$eq" : { "$numberLong" : "9007199254740993" } }, "min" : { "$numberLong" : "-9223372036854775808" }, \ + "i" : { "$numberInt" : "7" }, "imin" : { "$numberInt" : "-2147483648" }, \ + "dec" : { "$numberDecimal" : "1.50" }, "decnan" : { "$numberDecimal" : "NaN" }, \ + "f" : { "$numberDouble" : "2.5" }, "tenth" : { "$numberDouble" : "0.10000000000000000555" }, \ + "neg0" : { "$numberDouble" : "-0.0" }, "bigd" : { "$numberDouble" : "1e+20" }, \ + "wide" : { "$numberDouble" : "9007199254740992.0" }, \ + "inf" : { "$numberDouble" : "Infinity" }, "ninf" : { "$numberDouble" : "-Infinity" }, \ + "nan" : { "$numberDouble" : "NaN" }, \ + "when" : { "$date" : { "$numberLong" : "1577934245678" } }, \ + "reform" : { "$date" : { "$numberLong" : "-12219292800001" } }, \ + "first" : { "$date" : { "$numberLong" : "-62135596800000" } }, \ + "old" : { "$date" : { "$numberLong" : "-62198755200000" } }, \ + "late" : { "$date" : { "$numberLong" : "253402300800000" } }, \ + "decinf" : { "$numberDecimal" : "-Infinity" }, "sym" : { "$symbol" : "q" }, \ + "__proto__" : { "$numberInt" : "1" }, "inner" : { "__proto__" : { "x" : { "$numberLong" : "3" } } }, \ + "oid" : { "$oid" : "5f1d7a3b2c4e5a6b7c8d9e0f" }, \ + "bin" : { "$binary" : { "base64" : "AAECAwQFBgcICQoLDA0ODw==", "subType" : "04" } }, \ + "user" : { "$binary" : { "base64" : "AAEC", "subType" : "80" } }, \ + "code" : { "$code" : "x", "$scope" : { "y" : { "$numberLong" : "2" } } }, \ + "many" : { "$in" : [ { "$minKey" : 1 }, { "$maxKey" : 1 }, { "$timestamp" : { "t" : 5, "i" : 6 } }, \ + { "$regularExpression" : { "pattern" : "a\\\\/b", "options" : "i" } }, null, true, "s" ] } } }, \ + { "$sort" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberInt" : "-1" } } } ] + """ + + private static let collation = """ + { "locale" : "en", "caseLevel" : false, "strength" : { "$numberInt" : "2" }, "version" : "57.1" } + """ + + private static var view: String { + """ + { "name" : "typed", "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \(pipeline), \ + "collation" : \(collation) }, "info" : { "readOnly" : true } } + """ + } + + private static let index = """ + { "v" : { "$numberInt" : "2" }, "key" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberDouble" : "1.0" } }, \ + "name" : "typed_idx", "partialFilterExpression" : { "big" : { "$gt" : { "$numberLong" : "9007199254740993" } }, \ + "w" : { "$gte" : { "$numberDouble" : "1.0" } }, "a" : { "$gt" : { "$numberLong" : "2" } } }, \ + "expireAfterSeconds" : { "$numberLong" : "3600" }, "collation" : \(collation) } + """ + + private func requests(sentBy statement: String) throws -> RecordingHost { + let host = RecordingHost() + let context = try MongoScriptContext.make( + execute: { host.handle($0) }, + emit: { host.record(printed: $0) } + ) + context.evaluateScript(statement) + #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") + return host + } + + private func sentCommand(_ statement: String) throws -> String { + let commands = try requests(sentBy: statement).requests(op: "command") + #expect(commands.count == 1) + return try #require(commands.first?["command"] as? String) + } + + private func member(_ key: String, of json: String) throws -> String { + try #require(MongoScriptJson.member(of: json, key: key)) + } + + @Test("Edit View Definition sends the pipeline the catalog holds, every value in its own type") + func collModSendsTheCatalogPipeline() throws { + let statement = try #require(MongoDBNamespaceEntry(json: Self.view)?.collModStatement()) + let command = try sentCommand(statement) + + #expect(BsonTypedText.of(try member("pipeline", of: command)) == BsonTypedText.of(Self.pipeline)) + #expect(BsonTypedText.of(try member("viewOn", of: command)) == "\"src\"") + } + + @Test("A view's Show DDL sends the catalog's pipeline and its collation, less the ICU version") + func createViewSendsTheCatalogPipeline() throws { + let statement = try #require(MongoDBNamespaceEntry(json: Self.view)?.createViewStatement()) + let command = try sentCommand(statement) + + #expect(BsonTypedText.of(try member("pipeline", of: command)) == BsonTypedText.of(Self.pipeline)) + #expect(BsonTypedText.of(try member("collation", of: command)) + == BsonTypedText.of(MongoDBCollation.portable(Self.collation))) + } + + @Test("An index's Show DDL builds the spec the catalog holds, every value in its own type") + func createIndexBuildsTheCatalogSpec() throws { + let entry = try #require(MongoDBIndexEntry(json: Self.index)) + let host = try requests(sentBy: entry.createIndexStatement(collection: "src")) + let request = try #require(host.requests(op: "createIndex").first) + let keys = try #require(request["keys"] as? String) + let options = try #require(request["options"] as? String) + + let command = MongoScriptCommandBuilder.createIndex(collection: "src", keys: keys, optionsJson: options) + let indexes = try member("indexes", of: command) + let spec = try #require(MongoScriptJson.topLevelElements(indexes).first) + let expected = MongoDBJsonLayout.object([(key: "key", value: entry.keyJson)] + entry.options) + #expect(BsonTypedText.of(spec) == BsonTypedText.of(expected)) + #expect(expected.contains("\"expireAfterSeconds\" : { \"$numberLong\" : \"3600\" }")) + #expect(!expected.contains("57.1")) + } + + @Test("The view's DDL names every value through a constructor mongosh also has, so no wrapper reaches it as a document") + func definitionsCarryNoWrappers() throws { + let entry = try #require(MongoDBNamespaceEntry(json: Self.view)) + let statements = [try #require(entry.createViewStatement()), try #require(entry.collModStatement())] + let wrappers = [ + "$numberInt", "$numberLong", "$numberDouble", "$numberDecimal", "$date", "$oid", "$binary", + "$timestamp", "$regularExpression", "$symbol", "$minKey", "$maxKey", "$code" + ] + + for statement in statements { + for wrapper in wrappers { + #expect(!statement.contains("\"\(wrapper)\""), "\(wrapper)") + } + #expect(statement.contains("[\"__proto__\"]: 1")) + #expect(statement.contains(#"BSONRegExp("a\\/b", "i")"#)) + } + } + + @Test("Double keeps a whole value and a negative zero a Double") + func doubleConstructorSendsADouble() throws { + let command = try sentCommand(""" + db.runCommand({a: Double(1), b: Double(-0.0), c: Double("2.5"), d: Double(1e21), \ + e: Double(NumberLong("5")), f: Double(NaN), g: Double(-Infinity)}) + """) + + #expect(command == """ + {"a":{"$numberDouble":"1"},"b":{"$numberDouble":"-0.0"},"c":{"$numberDouble":"2.5"},\ + "d":{"$numberDouble":"1e+21"},"e":{"$numberDouble":"5"},"f":{"$numberDouble":"NaN"},\ + "g":{"$numberDouble":"-Infinity"}} + """) + } + + @Test("Double reads as its number in arithmetic and refuses text that is not one") + func doubleConstructorIsANumber() throws { + let host = RecordingHost() + let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) + + #expect(context.evaluateScript("Double(2.5) + 1")?.toDouble() == 3.5) + #expect(context.evaluateScript("new Double(4) instanceof Double")?.toBool() == true) + context.evaluateScript("Double(\"abc\")") + #expect(context.exception?.toString() == "Error: Double takes a number") + } +} + +/// Extended JSON text reduced to what BSON holds: each number and date names its type and value, +/// so `1` and `1.0` differ when they are two types, and `"1"` and `"1.0"` agree when both spell +/// the same Double. +private enum BsonTypedText { + static func of(_ json: String) -> String { + let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("[") { + return "[" + MongoScriptJson.topLevelElements(trimmed).map(of).joined(separator: ",") + "]" + } + guard trimmed.hasPrefix("{") else { return trimmed } + let members = MongoScriptJson.members(of: trimmed) + if members.count == 1, let typed = typed(members[0]) { return typed } + return "{" + members.map { "\($0.key):\(of($0.value))" }.joined(separator: ",") + "}" + } + + private static func typed(_ member: (key: String, value: String)) -> String? { + let text = MongoScriptJson.decodedString(member.value) + switch member.key { + case "$numberInt": return text.map { "int32(\($0))" } + case "$numberLong": return text.map { "int64(\($0))" } + case "$numberDouble": return text.flatMap { Double($0) }.map { "double(\($0.bitPattern))" } + case "$date": + return MongoScriptJson.member(of: member.value, key: "$numberLong") + .flatMap(MongoScriptJson.decodedString) + .map { "date(\($0))" } + default: return nil + } + } +} diff --git a/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift b/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift new file mode 100644 index 000000000..aa7adc630 --- /dev/null +++ b/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift @@ -0,0 +1,47 @@ +// +// MongoScriptJsonEscapeTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoScriptJsonEscapeTests { + @Test("Member names are decoded, so a quote or a backslash in a field name survives") + func memberNamesAreDecoded() { + let members = MongoScriptJson.members(of: "{\"a\\\"b\": 1, \"c\\\\d\": 2, \"e\\u00e9\": 3, \"f\\ng\": 4}") + + #expect(members.map(\.key) == ["a\"b", "c\\d", "e\u{e9}", "f\ng"]) + #expect(members.map(\.value) == ["1", "2", "3", "4"]) + } + + @Test("A member is found by its decoded name") + func memberByDecodedName() { + #expect(MongoScriptJson.member(of: "{\"x\": 0, \"a\\\"b\": {\"c\": 1}}", key: "a\"b") == "{\"c\": 1}") + } + + @Test("A surrogate pair decodes to the character it encodes") + func surrogatePair() { + #expect(MongoScriptJson.members(of: "{\"\\ud83d\\ude00\": 1}").map(\.key) == ["\u{1F600}"]) + } + + @Test("A string is written with every character that ends a line or cannot be seen escaped, and reads back whole") + func jsonStringEscapesLineEndings() throws { + let value = "a\nb\rc\td\u{0B}e\u{85}f\u{2028}g\u{2029}h\u{7F}i\u{9F}j\"k\\l */ m' n" + let written = MongoScriptJson.jsonString(value) + + #expect(written == #""a\nb\rc\td\u000be\u0085f\u2028g\u2029h\u007fi\u009fj\"k\\l */ m' n""#) + #expect(!written.contains { $0.isNewline }) + #expect(try JSONDecoder().decode(String.self, from: Data(written.utf8)) == value) + #expect(MongoScriptJson.decodedString(written) == value) + } + + @Test("A string value's text decodes to the string, and anything else to nil") + func decodedString() { + #expect(MongoScriptJson.decodedString("\"x\\ny\"") == "x\ny") + #expect(MongoScriptJson.decodedString(" \"a\\\"b\" ") == "a\"b") + #expect(MongoScriptJson.decodedString("1") == nil) + #expect(MongoScriptJson.decodedString("{ \"a\" : 1 }") == nil) + #expect(MongoScriptJson.decodedString("\"unterminated") == nil) + } +} diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift new file mode 100644 index 000000000..7754e55e9 --- /dev/null +++ b/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift @@ -0,0 +1,93 @@ +// +// MongoScriptPreludeMemberTests.swift +// TableProTests +// + +import Foundation +import JavaScriptCore +import Testing + +@testable import TablePro + +/// What the shell sends for members and values that JavaScript itself treats specially: a member +/// named `__proto__`, which an assignment turns into the object's prototype, and the BSON types a +/// canonical wrapper used to stand in for. +struct MongoScriptPreludeMemberTests { + private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost + + private func run(_ script: String, replies: [String] = []) throws -> (RecordingHost, JSContext) { + let host = RecordingHost() + host.replies = replies + let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) + context.evaluateScript(script) + #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") + return (host, context) + } + + private func commands(_ host: RecordingHost) -> [String] { + host.requests(op: "command").compactMap { $0["command"] as? String } + } + + @Test("A member named __proto__ is sent as a member, at any depth") + func protoMembersAreSent() throws { + let (host, _) = try run(""" + db.runCommand({ insert: "c", documents: [{ ["__proto__"]: 1, a: { ["__proto__"]: { b: 2 } } }] }) + """) + + #expect(commands(host) == [ + #"{"insert":"c","documents":[{"__proto__":{"$numberInt":"1"},"a":{"__proto__":{"b":{"$numberInt":"2"}}}}]}"# + ]) + } + + @Test("A member named __proto__ read from the server stays a member, and is sent back as one") + func protoMembersSurviveAReadAndAWrite() throws { + let reply = #"{"cursor":{"firstBatch":[{"__proto__":{"$numberInt":"7"},"k":"v"}]}}"# + let (host, context) = try run(""" + var found = db.runCommand({ find: "c" }).cursor.firstBatch[0]; + db.runCommand({ insert: "c", documents: [found] }); + """, replies: [reply, "{}"]) + + #expect(context.evaluateScript("Object.keys(found).join()")?.toString() == "__proto__,k") + #expect(context.evaluateScript("found.__proto__ === 7")?.toBool() == true) + #expect(commands(host).last == #"{"insert":"c","documents":[{"__proto__":{"$numberInt":"7"},"k":"v"}]}"#) + } + + @Test("createCollection and createView pass an option named __proto__ on as a member") + func protoOptionsAreSent() throws { + let (host, _) = try run(""" + db.createCollection("c", { ["__proto__"]: 1 }); + db.createView("v", "c", [], { ["__proto__"]: 2 }); + """, replies: ["{}", "{}"]) + + #expect(commands(host) == [ + #"{"create":"c","__proto__":{"$numberInt":"1"}}"#, + #"{"create":"v","viewOn":"c","pipeline":[],"__proto__":{"$numberInt":"2"}}"# + ]) + } + + @Test("BSONRegExp, BSONSymbol and a NaN or infinite NumberDecimal send the BSON types they name") + func constructorsSendTheirTypes() throws { + let (host, _) = try run(""" + db.runCommand({ r: BSONRegExp("(?i)a\\\\/b", "xi"), s: BSONSymbol("q"), n: NumberDecimal("NaN"), + p: NumberDecimal("Infinity"), m: NumberDecimal("-Infinity"), d: new Date(-62198755200000) }) + """) + + #expect(commands(host) == [ + #"{"r":{"$regularExpression":{"pattern":"(?i)a\\/b","options":"ix"}},"s":{"$symbol":"q"},"# + + #""n":{"$numberDecimal":"NaN"},"p":{"$numberDecimal":"Infinity"},"# + + #""m":{"$numberDecimal":"-Infinity"},"d":{"$date":{"$numberLong":"-62198755200000"}}}"# + ]) + } + + @Test("BSONRegExp refuses options BSON has no flag for, and NumberDecimal still refuses a word") + func constructorsRefuseWhatBsonCannotHold() throws { + let host = RecordingHost() + let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) + + context.evaluateScript("BSONRegExp(\"a\", \"g\")") + #expect(context.exception?.toString() == "Error: BSONRegExp takes options from i, l, m, s, u and x") + context.exception = nil + context.evaluateScript("NumberDecimal(\"abc\")") + #expect(context.exception?.toString() == "Error: NumberDecimal takes a number") + } +} diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift new file mode 100644 index 000000000..4ad07abbe --- /dev/null +++ b/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift @@ -0,0 +1,49 @@ +// +// MongoScriptPreludeViewTests.swift +// TableProTests +// + +import Foundation +import JavaScriptCore +import Testing + +/// Drives the real prelude, where `db.createView` used to resolve to a collection named +/// `createView` and throw "db.createView is not a function". +struct MongoScriptPreludeViewTests { + private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost + + private func makeContext(_ host: RecordingHost) throws -> JSContext { + try MongoScriptContext.make( + execute: { host.handle($0) }, + emit: { host.record(printed: $0) } + ) + } + + private func command(sentBy statement: String) throws -> String { + let host = RecordingHost() + let context = try makeContext(host) + + context.evaluateScript(statement) + #expect(context.exception == nil) + + let commands = host.requests(op: "command") + #expect(commands.count == 1) + return try #require(commands.first?["command"] as? String) + } + + @Test("createView sends create with viewOn, the pipeline and the options, in that order") + func createViewSendsCreateWithViewOn() throws { + let command = try command( + sentBy: "db.createView(\"adults\", \"people\", [{$match: {}}], {collation: {locale: \"en\"}})" + ) + + #expect(command == """ + {"create":"adults","viewOn":"people","pipeline":[{"$match":{}}],"collation":{"locale":"en"}} + """) + } + + @Test("createView without a pipeline sends an empty one") + func createViewDefaultsToEmptyPipeline() throws { + #expect(try command(sentBy: "db.createView(\"v\", \"s\")") == "{\"create\":\"v\",\"viewOn\":\"s\",\"pipeline\":[]}") + } +} diff --git a/TableProTests/Plugins/MongoDBIndexEntryTests.swift b/TableProTests/Plugins/MongoDBIndexEntryTests.swift new file mode 100644 index 000000000..a3c9c36e1 --- /dev/null +++ b/TableProTests/Plugins/MongoDBIndexEntryTests.swift @@ -0,0 +1,205 @@ +// +// MongoDBIndexEntryTests.swift +// TableProTests +// +// Fixtures are listIndexes documents as libmongoc 1.28 renders them in canonical Extended JSON, +// read from MongoDB 7.0.43. +// + +import Foundation +import TableProPluginKit +import Testing + +struct MongoDBIndexEntryTests { + private func entry(_ json: String) throws -> MongoDBIndexEntry { + try #require(MongoDBIndexEntry(json: json)) + } + + private func int(_ value: Int) -> String { + "{ \"$numberInt\" : \"\(value)\" }" + } + + @Test("A compound key keeps the order the server sent, in the columns and in the statement") + func compoundKeyKeepsServerOrder() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "zeta" : \(int(1)), "alpha" : \(int(1)), "mid" : \(int(-1)), \ + "beta" : \(int(1)), "omega" : \(int(-1)), "delta" : \(int(1)) }, \ + "name" : "zeta_1_alpha_1_mid_-1_beta_1_omega_-1_delta_1", "unique" : true, "sparse" : true } + """) + + #expect(index.columns == ["zeta", "alpha", "mid", "beta", "omega", "delta"]) + #expect(index.isUnique) + #expect(index.kind == .btree) + #expect(index.createIndexStatement(collection: "people") == """ + db.people.createIndex({ "zeta" : 1, "alpha" : 1, "mid" : -1, "beta" : 1, "omega" : -1, "delta" : 1 }, \ + { "name" : "zeta_1_alpha_1_mid_-1_beta_1_omega_-1_delta_1", "unique" : true, "sparse" : true }) + """) + } + + @Test("A descending field keeps its direction") + func directionSurvives() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "lastName" : \(int(1)), "firstName" : \(int(-1)) }, \ + "name" : "lastName_1_firstName_-1" } + """) + + #expect(index.columns == ["lastName", "firstName"]) + #expect(index.createIndexStatement(collection: "people").contains("\"firstName\" : -1")) + #expect(!index.isUnique) + } + + @Test("A TTL index keeps its expiry") + func ttlOptionIsKept() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "ts" : \(int(1)) }, "name" : "ts_1", "expireAfterSeconds" : \(int(3_600)) } + """) + + #expect(index.createIndexStatement(collection: "people") + == "db.people.createIndex({ \"ts\" : 1 }, { \"name\" : \"ts_1\", \"expireAfterSeconds\" : 3600 })") + } + + @Test("A partial filter keeps its member order and the collation loses only its ICU version") + func partialFilterAndCollationAreKept() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "age" : \(int(1)) }, "name" : "age_1", \ + "partialFilterExpression" : { "b" : { "$gt" : \(int(1)) }, "age" : { "$exists" : true } }, \ + "collation" : { "locale" : "fr", "caseLevel" : false, "strength" : \(int(2)), "version" : "57.1" } } + """) + + #expect(index.createIndexStatement(collection: "people") == """ + db.people.createIndex({ "age" : 1 }, { "name" : "age_1", \ + "partialFilterExpression" : { "b" : { "$gt" : 1 }, "age" : { "$exists" : true } }, \ + "collation" : { "locale" : "fr", "caseLevel" : false, "strength" : 2 } }) + """) + } + + @Test("An Int64 and a whole Double in a key or a partial filter keep their types in the statement") + func typedValuesKeepTheirTypes() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "w" : { "$numberDouble" : "1.0" } }, "name" : "typed", \ + "partialFilterExpression" : { "big" : { "$gt" : { "$numberLong" : "9007199254740993" } }, \ + "w" : { "$gte" : { "$numberDouble" : "1.0" } }, "a" : { "$gt" : { "$numberLong" : "2" } }, \ + "f" : { "$lt" : { "$numberDouble" : "2.5" } } }, "expireAfterSeconds" : { "$numberLong" : "3600" } } + """) + + #expect(index.createIndexStatement(collection: "src") == """ + db.src.createIndex({ "w" : Double(1.0) }, { "name" : "typed", \ + "partialFilterExpression" : { "big" : { "$gt" : NumberLong("9007199254740993") }, \ + "w" : { "$gte" : Double(1.0) }, "a" : { "$gt" : NumberLong("2") }, "f" : { "$lt" : 2.5 } }, \ + "expireAfterSeconds" : NumberLong("3600") }) + """) + } + + @Test("The catalog's own members never reach createIndex") + func versionAndNamespaceAreDropped() throws { + let index = try entry(""" + { "v" : \(int(1)), "key" : { "a" : \(int(1)) }, "name" : "a_1", "ns" : "shop.orders", "sparse" : true } + """) + + #expect(index.options.map(\.key) == ["name", "sparse"]) + } + + @Test("A text index lists its weighted fields where _fts stands, between its other key fields") + func textIndexListsWeightedFields() throws { + let index = try entry(""" + { "v" : \(int(2)), "key" : { "a" : \(int(1)), "_fts" : "text", "_ftsx" : \(int(1)), "z" : \(int(-1)) }, \ + "name" : "a_1_alpha_text_zeta_text_z_-1", "weights" : { "alpha" : \(int(1)), "zeta" : \(int(1)) }, \ + "default_language" : "english", "language_override" : "language", "textIndexVersion" : \(int(3)) } + """) + + #expect(index.columns == ["a", "alpha", "zeta", "z"]) + #expect(index.kind == .text) + #expect(index.pluginIndexInfo.type == "FULLTEXT") + let statement = index.createIndexStatement(collection: "notes") + for option in ["\"weights\" : { \"alpha\" : 1, \"zeta\" : 1 }", "\"default_language\" : \"english\"", + "\"language_override\" : \"language\"", "\"textIndexVersion\" : 3"] { + #expect(statement.contains(option)) + } + } + + @Test("The key decides the index type, and every option of that type is kept") + func kindFromKey() throws { + let cases: [(json: String, type: String, option: String?)] = [ + ("{ \"v\" : \(int(2)), \"key\" : { \"h\" : \"hashed\" }, \"name\" : \"h_hashed\" }", "HASH", nil), + ( + """ + { "v" : \(int(2)), "key" : { "loc" : "2dsphere" }, "name" : "loc_2dsphere", \ + "2dsphereIndexVersion" : \(int(3)) } + """, + "SPATIAL", "\"2dsphereIndexVersion\" : 3" + ), + ( + """ + { "v" : \(int(2)), "key" : { "p" : "2d" }, "name" : "p_2d", "bits" : \(int(20)), \ + "min" : { "$numberDouble" : "-500.0" }, "max" : { "$numberDouble" : "500.0" } } + """, + "2D", "\"bits\" : 20, \"min\" : Double(-500.0), \"max\" : Double(500.0)" + ), + ( + """ + { "v" : \(int(2)), "key" : { "$**" : \(int(1)) }, "name" : "$**_1", \ + "wildcardProjection" : { "alpha" : \(int(1)), "zeta" : \(int(1)) } } + """, + "WILDCARD", "\"wildcardProjection\" : { \"alpha\" : 1, \"zeta\" : 1 }" + ), + ("{ \"v\" : \(int(2)), \"key\" : { \"tags.$**\" : \(int(1)) }, \"name\" : \"tags.$**_1\" }", "WILDCARD", nil), + ( + "{ \"v\" : \(int(2)), \"key\" : { \"omega\" : \(int(1)) }, \"name\" : \"omega_1\", \"hidden\" : true }", + "BTREE", "\"hidden\" : true" + ), + ( + "{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)), \"loc\" : \"2dsphere\" }, \"name\" : \"a_1_loc_2dsphere\" }", + "SPATIAL", nil + ) + ] + + for testCase in cases { + let index = try entry(testCase.json) + #expect(index.pluginIndexInfo.type == testCase.type, "\(testCase.json)") + if let option = testCase.option { + #expect(index.createIndexStatement(collection: "c").contains(option), "\(testCase.json)") + } + } + } + + @Test("The four types the structure editor writes read back as the names it wrote them under") + func kindNamesMatchTheStructureEditor() { + let spellings: [(value: String, type: String)] = [ + (int(1), "BTREE"), ("\"hashed\"", "HASH"), ("\"text\"", "FULLTEXT"), ("\"2dsphere\"", "SPATIAL") + ] + for spelling in spellings { + #expect(MongoDBIndexKind(keyFields: [(name: "a", value: spelling.value)]).pluginTypeName == spelling.type) + } + } + + @Test("_id_ is the primary key and unique without saying so") + func idIndexIsPrimaryAndUnique() throws { + let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"_id\" : \(int(1)) }, \"name\" : \"_id_\" }") + + #expect(index.isPrimary) + #expect(index.isUnique) + #expect(index.pluginIndexInfo.columns == ["_id"]) + } + + @Test("A field name holding a quote and a backslash comes back decoded and goes out escaped") + func fieldNameWithQuoteRoundTrips() throws { + let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"a\\\"b\\\\c\" : \(int(1)) }, \"name\" : \"a\\\"b\\\\c_1\" }") + + #expect(index.columns == ["a\"b\\c"]) + #expect(index.name == "a\"b\\c_1") + #expect(index.createIndexStatement(collection: "c").hasSuffix("{ \"name\" : \"a\\\"b\\\\c_1\" })")) + } + + @Test("A collection whose name the shell would read as a method is reached through getCollection") + func shadowedCollectionName() throws { + let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)) }, \"name\" : \"a_1\" }") + + #expect(index.createIndexStatement(collection: "stats").hasPrefix("db.getCollection(\"stats\").createIndex(")) + } + + @Test("A document with no key or no name is not an index") + func incompleteDocumentsAreSkipped() { + #expect(MongoDBIndexEntry(json: "{ \"v\" : \(int(2)), \"name\" : \"a_1\" }") == nil) + #expect(MongoDBIndexEntry(json: "{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)) } }") == nil) + } +} diff --git a/TableProTests/Plugins/MongoDBJsonLayoutTests.swift b/TableProTests/Plugins/MongoDBJsonLayoutTests.swift new file mode 100644 index 000000000..628415994 --- /dev/null +++ b/TableProTests/Plugins/MongoDBJsonLayoutTests.swift @@ -0,0 +1,111 @@ +// +// MongoDBJsonLayoutTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoDBJsonLayoutTests { + @Test("Members keep their order, one per line, nested two spaces a level") + func indentedKeepsOrder() { + let text = MongoDBJsonLayout.indented("{ \"zeta\" : 1, \"alpha\" : { \"b\" : [ 1, 2 ] } }") + + #expect(text == """ + { + "zeta": 1, + "alpha": { + "b": [ + 1, + 2 + ] + } + } + """) + } + + @Test("Empty documents and arrays stay on one line") + func emptyContainers() { + #expect(MongoDBJsonLayout.indented("{ \"a\" : { }, \"b\" : [ ] }") == "{\n \"a\": {},\n \"b\": []\n}") + #expect(MongoDBJsonLayout.indented("[ ]") == "[]") + } + + @Test("Punctuation and escapes inside a string are left as they are") + func stringsAreUntouched() { + let text = MongoDBJsonLayout.indented("{ \"k\" : \"a, {b}: [c] \\\" d\" }") + + #expect(text == "{\n \"k\": \"a, {b}: [c] \\\" d\"\n}") + } + + @Test("A depth indents every line after the first for text that sits inside a statement") + func depthOffsetsNesting() { + #expect(MongoDBJsonLayout.indented("{ \"a\" : 1 }", depth: 1) == "{\n \"a\": 1\n }") + } + + @Test("An object built from members uses libbson's own spacing") + func objectSpacing() { + #expect(MongoDBJsonLayout.object([(key: "name", value: "\"a_1\""), (key: "unique", value: "true")]) + == "{ \"name\" : \"a_1\", \"unique\" : true }") + #expect(MongoDBJsonLayout.object([]) == "{ }") + } + + @Test("A constructor call stays on one line, commas and braces inside it included") + func constructorCallsStayWhole() { + let text = MongoDBJsonLayout.indented( + "[ { \"b\" : BinData(4, \"AA==\"), \"t\" : Timestamp(5, 6), \"c\" : Code(\"f()\", { \"x\" : 1 }) } ]" + ) + + #expect(text == """ + [ + { + "b": BinData(4, "AA=="), + "t": Timestamp(5, 6), + "c": Code("f()", { "x" : 1 }) + } + ] + """) + } + + @Test("A shell object writes __proto__ as a computed key, and an Extended JSON object as a plain one") + func shellObjectKeepsProtoAMember() { + let members = [(key: "__proto__", value: "1"), (key: "a", value: "2")] + + #expect(MongoDBJsonLayout.shellObject(members) == "{ [\"__proto__\"] : 1, \"a\" : 2 }") + #expect(MongoDBJsonLayout.object(members) == "{ \"__proto__\" : 1, \"a\" : 2 }") + #expect(MongoDBJsonLayout.shellObject([]) == "{ }") + } + + @Test("A computed key stays on its line, and an array holding one string does not become one") + func computedKeysStayWhole() { + let text = MongoDBJsonLayout.indented( + "{ [\"__proto__\"] : { [\"__pro\\\"to__\"] : 1 }, \"b\" : [ \"x\" ], \"c\" : [ \"y\" ] }" + ) + + #expect(text == """ + { + ["__proto__"]: { + ["__pro\\"to__"]: 1 + }, + "b": [ + "x" + ], + "c": [ + "y" + ] + } + """) + } + + @Test("new Date keeps the space between its two words") + func newDateKeepsItsSpace() { + #expect(MongoDBJsonLayout.indented("{ \"d\" : new Date(-62198755200000) }") + == "{\n \"d\": new Date(-62198755200000)\n}") + } + + @Test("A collation keeps every member but the server's ICU version") + func portableCollation() { + #expect(MongoDBCollation.portable( + "{ \"locale\" : \"en\", \"strength\" : { \"$numberInt\" : \"2\" }, \"version\" : \"57.1\" }" + ) == "{ \"locale\" : \"en\", \"strength\" : { \"$numberInt\" : \"2\" } }") + } +} diff --git a/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift b/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift new file mode 100644 index 000000000..b4b68f240 --- /dev/null +++ b/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift @@ -0,0 +1,297 @@ +// +// MongoDBNamespaceEntryTests.swift +// TableProTests +// +// Fixtures are listCollections entries as libmongoc 1.28 renders them in canonical Extended JSON, +// read from MongoDB 7.0.43. +// + +import Foundation +import Testing + +@testable import TablePro + +struct MongoDBNamespaceEntryTests { + private static let adults = """ + { "name" : "adults", "type" : "view", "options" : { "viewOn" : "people", "pipeline" : \ + [ { "$match" : { "age" : { "$gte" : { "$numberInt" : "4" } } } }, \ + { "$sort" : { "lastName" : { "$numberInt" : "1" }, "firstName" : { "$numberInt" : "-1" } } } ], \ + "collation" : { "locale" : "en", "strength" : { "$numberInt" : "2" }, "version" : "57.1" } }, \ + "info" : { "readOnly" : true } } + """ + + private static let plainView = """ + { "name" : "plainview", "type" : "view", "options" : { "viewOn" : "people", "pipeline" : \ + [ { "$project" : { "lastName" : { "$numberInt" : "1" } } } ] }, "info" : { "readOnly" : true } } + """ + + private static let typedView = """ + { "name" : "typed", "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \ + [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "w" : { "$eq" : { "$numberDouble" : "1.0" } }, \ + "big" : { "$eq" : { "$numberLong" : "9007199254740993" } }, "i" : { "$numberInt" : "7" }, \ + "neg0" : { "$numberDouble" : "-0.0" }, "when" : { "$date" : { "$numberLong" : "1577934245678" } }, \ + "many" : { "$in" : [ { "$minKey" : 1 }, { "$timestamp" : { "t" : 5, "i" : 6 } } ] } } } ], \ + "collation" : { "locale" : "en", "strength" : { "$numberInt" : "2" }, "version" : "57.1" } }, \ + "info" : { "readOnly" : true } } + """ + + private static let int1 = "{ \"$numberInt\" : \"1\" }" + private static let int2 = "{ \"$numberInt\" : \"2\" }" + + private func entry(_ json: String) throws -> MongoDBNamespaceEntry { + try #require(MongoDBNamespaceEntry(json: json)) + } + + private func index(_ json: String) throws -> MongoDBIndexEntry { + try #require(MongoDBIndexEntry(json: json)) + } + + @Test("Each kind of namespace listCollections reports maps to the table type the app reads") + func kindMapping() throws { + let expected: [(json: String, type: String)] = [ + ("{ \"name\" : \"people\", \"type\" : \"collection\" }", "TABLE"), + ("{ \"name\" : \"adults\", \"type\" : \"view\" }", "VIEW"), + ("{ \"name\" : \"metrics\", \"type\" : \"timeseries\" }", "TABLE"), + ("{ \"name\" : \"system.views\", \"type\" : \"collection\" }", "SYSTEM TABLE"), + ("{ \"name\" : \"system.buckets.metrics\", \"type\" : \"collection\" }", "SYSTEM TABLE"), + ("{ \"name\" : \"system.profile\", \"type\" : \"collection\" }", "SYSTEM TABLE"), + ("{ \"name\" : \"legacy\" }", "TABLE"), + ("{ \"name\" : \"systemx\", \"type\" : \"collection\" }", "TABLE") + ] + + for namespace in expected { + #expect(try entry(namespace.json).pluginTableType == namespace.type, "\(namespace.json)") + } + } + + @Test("The plugin's spelling decodes to the app's view, table and system table kinds") + func kindMappingDecodesInTheApp() throws { + let expected: [(json: String, kind: TableInfo.TableType)] = [ + ("{ \"name\" : \"adults\", \"type\" : \"view\" }", .view), + ("{ \"name\" : \"people\", \"type\" : \"collection\" }", .table), + ("{ \"name\" : \"metrics\", \"type\" : \"timeseries\" }", .table), + ("{ \"name\" : \"system.views\", \"type\" : \"collection\" }", .systemTable) + ] + + for namespace in expected { + let decoded = PluginTableKindDecoder.decode(try entry(namespace.json).pluginTableType) + #expect(decoded.kind == namespace.kind, "\(namespace.json)") + #expect(!decoded.isSystemVersioned) + } + } + + @Test("A view's statement keeps its pipeline's stage and $sort order, and its collation without the ICU version") + func createViewKeepsPipelineOrderAndCollation() throws { + #expect(try entry(Self.adults).createViewStatement() == """ + db.createView("adults", "people", [ + { + "$match": { + "age": { + "$gte": 4 + } + } + }, + { + "$sort": { + "lastName": 1, + "firstName": -1 + } + } + ], { + "collation": { + "locale": "en", + "strength": 2 + } + }) + """) + } + + @Test("A view with no collation is created with three arguments") + func createViewWithoutCollationHasThreeArguments() throws { + #expect(try entry(Self.plainView).createViewStatement() == """ + db.createView("plainview", "people", [ + { + "$project": { + "lastName": 1 + } + } + ]) + """) + } + + @Test("The collMod that redefines a view leaves its collation out, which collMod refuses") + func collModOmitsCollation() throws { + #expect(try entry(Self.adults).collModStatement() == """ + db.runCommand({ + "collMod": "adults", + "viewOn": "people", + "pipeline": [ + { + "$match": { + "age": { + "$gte": 4 + } + } + }, + { + "$sort": { + "lastName": 1, + "firstName": -1 + } + } + ] + }) + """) + } + + @Test("A view's Int64, whole Double, date and bounds are written through the constructors that keep their types") + func viewStatementsKeepValueTypes() throws { + let view = try entry(Self.typedView) + let match = """ + "$match": { + "a": { + "$gte": NumberLong("1") + }, + "w": { + "$eq": Double(1.0) + }, + "big": { + "$eq": NumberLong("9007199254740993") + }, + "i": 7, + "neg0": Double(-0.0), + "when": ISODate("2020-01-02T03:04:05.678Z"), + "many": { + "$in": [ + MinKey(), + Timestamp(5, 6) + ] + } + } + """ + + #expect(view.createViewStatement() == """ + db.createView("typed", "src", [ + { + \(match) + } + ], { + "collation": { + "locale": "en", + "strength": 2 + } + }) + """) + #expect(try #require(view.collModStatement()).contains("\"$eq\": NumberLong(\"9007199254740993\")")) + #expect(try #require(view.collModStatement()).contains("\"$eq\": Double(1.0)")) + } + + @Test("A collection has no view statements") + func nonViewHasNoViewStatements() throws { + let collection = try entry("{ \"name\" : \"people\", \"type\" : \"collection\", \"options\" : { } }") + + #expect(collection.createViewStatement() == nil) + #expect(collection.collModStatement() == nil) + } + + @Test("A view's DDL has its own header, so MQL export never appends it to exported documents") + func viewDDLHasViewHeaderAndNoIndexes() throws { + let text = MongoDBNamespaceDDL.text(name: "adults", entry: try entry(Self.adults), indexes: []) + + #expect(text.hasPrefix("// View: adults\ndb.createView(\"adults\", \"people\", [")) + #expect(!text.contains("// Collection:")) + #expect(!text.contains("// Indexes")) + } + + @Test("A capped collection's size and max are read from the options") + func cappedSizeIsRead() throws { + let capped = try entry(""" + { "name" : "capped1", "type" : "collection", "options" : { "capped" : true, \ + "size" : { "$numberInt" : "4096" }, "max" : { "$numberLong" : "10" } } } + """) + + #expect(MongoDBNamespaceDDL.text(name: "capped1", entry: capped, indexes: []) + == "// Collection: capped1\n// Capped: true, size: 4096, max: 10") + } + + @Test("A validator keeps its properties in declared order and the collection name is escaped") + func validatorKeepsPropertyOrder() throws { + let validated = try entry(""" + { "name" : "we\\"ird", "type" : "collection", "options" : { "validator" : { "$jsonSchema" : \ + { "bsonType" : "object", "properties" : { "zeta" : { "bsonType" : "string" }, \ + "alpha" : { "bsonType" : "long", "minimum" : { "$numberLong" : "9007199254740993" } } } } } } } + """) + + #expect(MongoDBNamespaceDDL.text(name: "we\"ird", entry: validated, indexes: []) == """ + // Collection: we"ird + + // Validator + db.runCommand({ + "collMod": "we\\"ird", + "validator": { + "$jsonSchema": { + "bsonType": "object", + "properties": { + "zeta": { + "bsonType": "string" + }, + "alpha": { + "bsonType": "long", + "minimum": NumberLong("9007199254740993") + } + } + } + } + }) + """) + } + + @Test("A time-series collection says so in its DDL") + func timeSeriesLineIsWritten() throws { + let metrics = try entry(""" + { "name" : "metrics", "type" : "timeseries", "options" : { "timeseries" : { "timeField" : "ts", \ + "metaField" : "meta", "granularity" : "hours", "bucketMaxSpanSeconds" : { "$numberInt" : "2592000" } } } } + """) + + #expect(MongoDBNamespaceDDL.text(name: "metrics", entry: metrics, indexes: []) == """ + // Collection: metrics + // Time series: { "timeField" : "ts", "metaField" : "meta", "granularity" : "hours", \ + "bucketMaxSpanSeconds" : 2592000 } + """) + } + + @Test("Indexes are written in listIndexes order and _id_ is left out") + func ddlSkipsIdIndexAndKeepsListOrder() throws { + let people = try entry("{ \"name\" : \"people\", \"type\" : \"collection\", \"options\" : { } }") + let indexes = try [ + index("{ \"v\" : \(Self.int2), \"key\" : { \"_id\" : \(Self.int1) }, \"name\" : \"_id_\" }"), + index("{ \"v\" : \(Self.int2), \"key\" : { \"zeta\" : \(Self.int1) }, \"name\" : \"zeta_1\" }"), + index("{ \"v\" : \(Self.int2), \"key\" : { \"alpha\" : \(Self.int1) }, \"name\" : \"alpha_1\" }") + ] + + #expect(MongoDBNamespaceDDL.text(name: "people", entry: people, indexes: indexes) == """ + // Collection: people + + // Indexes + db.people.createIndex({ "zeta" : 1 }, { "name" : "zeta_1" }) + db.people.createIndex({ "alpha" : 1 }, { "name" : "alpha_1" }) + """) + } + + @Test("A namespace whose entry could not be read still gets its header and indexes") + func nilEntryStillWritesIndexes() throws { + let indexes = try [index("{ \"v\" : \(Self.int2), \"key\" : { \"a\" : \(Self.int1) }, \"name\" : \"a_1\" }")] + + #expect(MongoDBNamespaceDDL.text(name: "people", entry: nil, indexes: indexes) == """ + // Collection: people + + // Indexes + db.people.createIndex({ "a" : 1 }, { "name" : "a_1" }) + """) + } + + @Test("An entry with no name is not a namespace") + func entryWithoutNameIsSkipped() { + #expect(MongoDBNamespaceEntry(json: "{ \"type\" : \"collection\" }") == nil) + } +} diff --git a/TableProTests/Plugins/MongoDBShellLiteralTests.swift b/TableProTests/Plugins/MongoDBShellLiteralTests.swift new file mode 100644 index 000000000..4abd87733 --- /dev/null +++ b/TableProTests/Plugins/MongoDBShellLiteralTests.swift @@ -0,0 +1,157 @@ +// +// MongoDBShellLiteralTests.swift +// TableProTests +// +// Inputs are canonical Extended JSON as libmongoc 1.28 renders it, read from MongoDB 7.0.43. +// + +import Foundation +import Testing + +struct MongoDBShellLiteralTests { + private func render(_ canonical: String) -> String { + MongoDBShellLiteral.render(canonical) + } + + @Test("An Int32 stays a bare number, and an Int64 and a whole Double name their type") + func numbersKeepTheirType() { + #expect(render(""" + { "a" : { "$numberInt" : "7" }, "b" : { "$numberLong" : "9007199254740993" }, \ + "c" : { "$numberLong" : "1" }, "d" : { "$numberDouble" : "1.0" }, "e" : { "$numberDouble" : "-0.0" }, \ + "f" : { "$numberDouble" : "1e+20" }, "g" : { "$numberInt" : "-2147483648" } } + """) == """ + { "a" : 7, "b" : NumberLong("9007199254740993"), "c" : NumberLong("1"), "d" : Double(1.0), \ + "e" : Double(-0.0), "f" : Double(1e+20), "g" : -2147483648 } + """) + } + + @Test("A fraction is written in the fewest digits that read back as the same Double") + func fractionsUseTheShortestSpelling() { + #expect(render("{ \"$numberDouble\" : \"0.10000000000000000555\" }") == "0.1") + #expect(render("{ \"$numberDouble\" : \"2.5\" }") == "2.5") + #expect(render("{ \"$numberDouble\" : \"-9.9999999999999995475e-08\" }") == "-1e-07") + } + + @Test("Infinity and NaN are the shell's own names for them") + func nonFiniteDoubles() { + #expect(render("{ \"$numberDouble\" : \"Infinity\" }") == "Infinity") + #expect(render("{ \"$numberDouble\" : \"-Infinity\" }") == "-Infinity") + #expect(render("{ \"$numberDouble\" : \"NaN\" }") == "NaN") + } + + @Test("Every Decimal128 goes through NumberDecimal, NaN and the infinities included") + func decimals() { + #expect(render("{ \"$numberDecimal\" : \"1.50\" }") == "NumberDecimal(\"1.50\")") + #expect(render("{ \"$numberDecimal\" : \"-1.23E-7\" }") == "NumberDecimal(\"-1.23E-7\")") + #expect(render("{ \"$numberDecimal\" : \"NaN\" }") == "NumberDecimal(\"NaN\")") + #expect(render("{ \"$numberDecimal\" : \"Infinity\" }") == "NumberDecimal(\"Infinity\")") + #expect(render("{ \"$numberDecimal\" : \"-Infinity\" }") == "NumberDecimal(\"-Infinity\")") + } + + @Test("A date is an ISODate in the proleptic Gregorian calendar JavaScript counts in") + func datesAreISODates() { + let cases: [(millis: String, text: String)] = [ + ("1577934245678", "2020-01-02T03:04:05.678Z"), + ("0", "1970-01-01T00:00:00.000Z"), + ("-1", "1969-12-31T23:59:59.999Z"), + ("951782400000", "2000-02-29T00:00:00.000Z"), + ("-12219292800001", "1582-10-14T23:59:59.999Z"), + ("-62135596800000", "0001-01-01T00:00:00.000Z"), + ("253402300799999", "9999-12-31T23:59:59.999Z") + ] + for testCase in cases { + #expect( + render("{ \"$date\" : { \"$numberLong\" : \"\(testCase.millis)\" } }") == "ISODate(\"\(testCase.text)\")", + "\(testCase.millis)" + ) + } + } + + @Test("A date outside the years ISODate can spell is a Date of its instant, as far as a Date reaches") + func farDatesAreDates() { + for millis in ["-62135596800001", "253402300800000", "8640000000000000", "-8640000000000000"] { + #expect(render("{ \"$date\" : { \"$numberLong\" : \"\(millis)\" } }") == "new Date(\(millis))", "\(millis)") + } + } + + @Test("A date past what a JavaScript Date holds keeps its wrapper, since no shell can write it otherwise") + func datesPastJavaScriptKeepTheirWrapper() { + for millis in ["8640000000000001", "-8640000000000001", "-9223372036854775808", "9223372036854775807"] { + let wrapper = "{ \"$date\" : { \"$numberLong\" : \"\(millis)\" } }" + #expect(render(wrapper) == wrapper, "\(millis)") + } + } + + @Test("Every other type with a shell constructor is written through it") + func otherConstructors() { + #expect(render("{ \"$oid\" : \"5f1d7a3b2c4e5a6b7c8d9e0f\" }") == "ObjectId(\"5f1d7a3b2c4e5a6b7c8d9e0f\")") + #expect(render("{ \"$binary\" : { \"base64\" : \"AAEC\", \"subType\" : \"04\" } }") == "BinData(4, \"AAEC\")") + #expect(render("{ \"$binary\" : { \"base64\" : \"AAEC\", \"subType\" : \"80\" } }") == "BinData(128, \"AAEC\")") + #expect(render("{ \"$timestamp\" : { \"t\" : 5, \"i\" : 6 } }") == "Timestamp(5, 6)") + #expect(render("{ \"$minKey\" : 1 }") == "MinKey()") + #expect(render("{ \"$maxKey\" : 1 }") == "MaxKey()") + #expect(render("{ \"$code\" : \"function () { return 1; }\" }") == "Code(\"function () { return 1; }\")") + #expect(render("{ \"$code\" : \"x\", \"$scope\" : { \"y\" : { \"$numberLong\" : \"2\" } } }") + == "Code(\"x\", { \"y\" : NumberLong(\"2\") })") + } + + @Test("A regular expression and a symbol go through the constructors mongosh names them with") + func regularExpressionsAndSymbols() { + #expect(render("{ \"$regularExpression\" : { \"pattern\" : \"a\\\\/b\", \"options\" : \"i\" } }") + == #"BSONRegExp("a\\/b", "i")"#) + #expect(render("{ \"$regularExpression\" : { \"pattern\" : \"(?x) a # b\\n\", \"options\" : \"lx\" } }") + == #"BSONRegExp("(?x) a # b\n", "lx")"#) + #expect(render("{ \"$symbol\" : \"q\\u2028r\" }") == #"BSONSymbol("q\u2028r")"#) + } + + @Test("A DBPointer and undefined, which mongosh cannot write, keep their wrapper") + func typesNoShellWritesKeepTheirWrapper() { + #expect(render("{ \"$undefined\" : true }") == "{ \"$undefined\" : true }") + #expect(render(""" + { "$dbPointer" : { "$ref" : "c", "$id" : { "$oid" : "5f1d7a3b2c4e5a6b7c8d9e0f" } } } + """) == """ + { "$dbPointer" : { "$ref" : "c", "$id" : ObjectId("5f1d7a3b2c4e5a6b7c8d9e0f") } } + """) + } + + @Test("A member named __proto__ is a computed key, which adds it as a member rather than a prototype") + func protoMembersAreComputedKeys() { + #expect(render(""" + { "__proto__" : { "$numberInt" : "1" }, "a" : { "__proto__" : { "b" : "c" } }, "proto" : "__proto__" } + """) == """ + { ["__proto__"] : 1, "a" : { ["__proto__"] : { "b" : "c" } }, "proto" : "__proto__" } + """) + } + + @Test("Query operators are documents, so what they hold is written and they are not") + func operatorsAreRecursedInto() { + #expect(render(""" + [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "b" : { "$in" : [ { "$numberInt" : "1" }, "s", null, true ] } } }, \ + { "$sort" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberInt" : "-1" } } } ] + """) == """ + [ { "$match" : { "a" : { "$gte" : NumberLong("1") }, "b" : { "$in" : [ 1, "s", null, true ] } } }, \ + { "$sort" : { "a" : 1, "w" : -1 } } ] + """) + } + + @Test("A string or a member name holding a line separator libbson left raw is written with it escaped") + func rawLineSeparatorsAreEscaped() { + let canonical = "{ \"k\u{2028}\" : \"v\u{2029}w\u{85}\", \"n\" : [ \"x\\ny\" ] }" + + #expect(render(canonical) == #"{ "k\u2028" : "v\u2029w\u0085", "n" : [ "x\ny" ] }"#) + } + + @Test("A value that is neither a literal nor a number is written as the string it spells") + func unknownScalarsBecomeStrings() { + #expect(render("{ \"a\" : 1.5e3, \"b\" : -0, \"c\" : undefined, \"d\" : x\u{2028}y }") + == #"{ "a" : 1.5e3, "b" : -0, "c" : "undefined", "d" : "x\u2028y" }"#) + } + + @Test("Strings, including ones that look like a wrapper, and empty containers are left alone") + func scalarsAndEmptyContainers() { + #expect(render("{ \"k\" : \"{ \\\"$numberLong\\\" : \\\"1\\\" }\" }") == "{ \"k\" : \"{ \\\"$numberLong\\\" : \\\"1\\\" }\" }") + #expect(render("[ ]") == "[ ]") + #expect(render("{ }") == "{ }") + #expect(render("\"text\"") == "\"text\"") + } +} diff --git a/TableProTests/Plugins/MongoDBShellTextTests.swift b/TableProTests/Plugins/MongoDBShellTextTests.swift new file mode 100644 index 000000000..3817b631b --- /dev/null +++ b/TableProTests/Plugins/MongoDBShellTextTests.swift @@ -0,0 +1,68 @@ +// +// MongoDBShellTextTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoDBShellTextTests { + @Test("A comment keeps a plain name as it is") + func plainCommentIsUnchanged() { + #expect(MongoDBShellText.comment("Collection: people") == "// Collection: people") + #expect(MongoDBShellText.comment("View: người dùng") == "// View: người dùng") + } + + @Test("Every character that ends a line or cannot be seen is written as its escape in a comment") + func commentEscapesLineEndings() { + let cases: [(name: String, spelled: String)] = [ + ("a\nb", "a\\nb"), + ("a\rb", "a\\rb"), + ("a\r\nb", "a\\r\\nb"), + ("a\tb", "a\\tb"), + ("a\u{2028}b", "a\\u2028b"), + ("a\u{2029}b", "a\\u2029b"), + ("a\u{85}b", "a\\u0085b"), + ("a\u{0B}b", "a\\u000bb"), + ("a\u{0C}b", "a\\u000cb"), + ("a\u{00}b", "a\\u0000b"), + ("a\u{7F}b", "a\\u007fb") + ] + for testCase in cases { + let line = MongoDBShellText.comment("View: \(testCase.name)") + + #expect(line == "// View: \(testCase.spelled)", "\(testCase.name.debugDescription)") + #expect(!line.contains { $0.isNewline }, "\(testCase.name.debugDescription)") + } + } + + @Test("A block comment closer and quotes cannot end a line comment, so they stay as they are") + func commentKeepsCloserAndQuotes() { + #expect(MongoDBShellText.comment("View: a */ b \" c ' d") == "// View: a */ b \" c ' d") + } + + @Test("A name made of identifier characters is reached with a dot") + func identifierNamesUseTheDot() { + #expect(MongoDBShellText.collection("people") == "db.people") + #expect(MongoDBShellText.collection("_audit2") == "db._audit2") + #expect(MongoDBShellText.collection("người_dùng") == "db.người_dùng") + } + + @Test("Any other name is reached through getCollection, escaped as a string") + func otherNamesUseGetCollection() { + let cases: [(name: String, expression: String)] = [ + ("stats", "db.getCollection(\"stats\")"), + ("2024", "db.getCollection(\"2024\")"), + ("a.b", "db.getCollection(\"a.b\")"), + ("a b", "db.getCollection(\"a b\")"), + ("a\"b", "db.getCollection(\"a\\\"b\")"), + ("a\nb", "db.getCollection(\"a\\nb\")"), + ("a\u{2028}b", "db.getCollection(\"a\\u2028b\")"), + ("a\u{2029}b", "db.getCollection(\"a\\u2029b\")"), + ("a\u{0D4E};b", "db.getCollection(\"a\u{0D4E};b\")") + ] + for testCase in cases { + #expect(MongoDBShellText.collection(testCase.name) == testCase.expression, "\(testCase.name.debugDescription)") + } + } +} diff --git a/TableProTests/Views/Main/ViewDefinitionFallbackTests.swift b/TableProTests/Views/Main/ViewDefinitionFallbackTests.swift new file mode 100644 index 000000000..dd09f50af --- /dev/null +++ b/TableProTests/Views/Main/ViewDefinitionFallbackTests.swift @@ -0,0 +1,77 @@ +// +// ViewDefinitionFallbackTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +/// What Edit View Definition opens when the view's definition could not be read: the error as +/// comments in the tab's own language, then the engine's template, and nothing else that runs. +@MainActor +struct ViewDefinitionFallbackTests { + private static let mongoTemplate = #"db.runCommand({ "collMod" : "v", "viewOn" : "source_collection" })"# + + /// The message a view named with every kind of line break carries: a carriage return alone, a + /// carriage return and line feed, a line feed, U+2028, U+2029 and U+0085. + private static let error = NSError(domain: "test", code: 1, userInfo: [ + NSLocalizedDescriptionKey: + "No view named v\rdb.probe.drop(); x\r\ndb.a.drop();\ndb.b.drop();\u{2028}db.c.drop();\u{2029}db.d.drop();\u{85}db.e.drop(); in this database" + ]) + + private func lines(_ text: String) -> [String] { + text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) + } + + @Test("A MongoDB tab gets the error as JavaScript comments, and the template is all that runs") + func mongoFallbackRunsOnlyTheTemplate() { + let text = MainContentCoordinator.viewDefinitionFallback( + viewName: "v", error: Self.error, template: Self.mongoTemplate, + lineComment: EditorLanguage.javascript.lineCommentMarker + ) + + let statements = JavaScriptStatementScanner.executableStatements(in: text).filter(\.hasContent) + let code = statements.map { + JavaScriptStatementScanner.strippingComments($0.text).trimmingCharacters(in: .whitespacesAndNewlines) + } + #expect(code == [Self.mongoTemplate]) + let commented = lines(text).dropLast() + #expect(commented.count == 8) + #expect(commented.allSatisfy { $0.hasPrefix("// ") }) + #expect(lines(text).last == Self.mongoTemplate) + } + + @Test("A SQL tab comments every line the error spans, whatever ends it") + func sqlFallbackCommentsEveryLine() { + let text = MainContentCoordinator.viewDefinitionFallback( + viewName: "v", error: Self.error, template: nil, + lineComment: EditorLanguage.sql.lineCommentMarker + ) + let all = lines(text) + + #expect(all.first == "-- " + String(localized: "Could not fetch the view definition:")) + #expect(all.dropLast(2).allSatisfy { $0.hasPrefix("-- ") }) + #expect(Array(all.suffix(2)) == ["CREATE OR REPLACE VIEW v AS", "SELECT * FROM table_name;"]) + } + + @Test("A language with no line comment gets the template alone") + func languageWithoutCommentsGetsTheTemplate() { + let text = MainContentCoordinator.viewDefinitionFallback( + viewName: "v", error: Self.error, template: Self.mongoTemplate, + lineComment: EditorLanguage.custom("surrealql").lineCommentMarker + ) + + #expect(text == Self.mongoTemplate) + } + + @Test("Each editor language comments a line the way its grammar does") + func lineCommentMarkers() { + #expect(EditorLanguage.sql.lineCommentMarker == "--") + #expect(EditorLanguage.javascript.lineCommentMarker == "//") + #expect(EditorLanguage.bash.lineCommentMarker == "#") + #expect(EditorLanguage.custom("kafkaql").lineCommentMarker.isEmpty) + } +} diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 8c4f2188d..54fc1cb7e 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -85,6 +85,27 @@ The filter bar's column picker lists paths inside nested objects and arrays of o The Structure tab lists a collection's indexes; drop one from a query tab with `db.users.dropIndex("email_1")`. **New Database** asks for a database name and a first collection, both required. **New View** opens a query tab holding a `db.createView("view_name", "source_collection", [pipeline])` template, and editing a view pre-fills `db.runCommand({"collMod": …})`. +### Views + +Views sit in their own **Views** group in the sidebar and open read-only, with no cell edits, **Add Row** or **Delete**, and no **Rename** or **Truncate** on the context menu. **Show DDL** writes the `db.createView(…)` that makes the view again, pipeline and collation included. **Edit View Definition** opens a `collMod` holding the view's source and pipeline: run it to change the view in place, collation kept. Both write each value in its own BSON type, so the text runs the same in a query tab and in mongosh: `NumberLong("…")` for an Int64, `Double(1.0)` for a whole Double, `ISODate("…")` for a date, `BSONRegExp("…", "…")` for a regular expression. A date before year 1 or after 9999 is written as `new Date(…)`. Export writes a view's documents; import and table transfer skip views. + +A time-series collection is listed with the other collections, and its DDL starts with a `// Time series:` line naming its time and meta fields. `system.views`, `system.profile` and the `system.buckets.*` collections behind time series are marked as system collections, with no **Rename** or **Truncate**. + +### Indexes + +The Structure tab's **Indexes** list shows each index's fields in key order, and its type from the key: + +| Key | Type | +|-----|------| +| `{a: 1}` or `{a: -1}` | **BTREE** | +| `{a: "hashed"}` | **HASH** | +| `{a: "text"}` | **FULLTEXT**, listing the fields in its weights | +| `{loc: "2dsphere"}` | **SPATIAL** | +| `{loc: "2d"}` | **2D** | +| `{"$**": 1}` or `{"tags.$**": 1}` | **WILDCARD** | + +**Show DDL** writes one `createIndex` per index with every option the server reports: TTL, partial filter, collation, text weights, wildcard projection and 2d bounds. Values are written the way a view's are, so run that text in a query tab and the indexes it builds match the originals, value types included. The collation leaves out the server's ICU `version`, so the statement also runs on a server built with a different ICU. + ### Missing fields and null A field a document does not have reads **No Field**; a field holding null reads NULL. **Set Value > NULL** stores `null` and keeps the field. To delete a field, right-click the cell and choose **Remove Field**, or choose it from the value menu of the field in the inspector; saving sends `$unset`. A validator that lists the field in `required` refuses the save with `Document failed validation`. @@ -213,8 +234,8 @@ Collection: `find`, `findOne`, `aggregate`, `countDocuments`/`count`, `estimated `totalIndexSize`, `totalSize`, `isCapped`, `validate`, `explain`. Database: `getCollection`, `getSiblingDB`, `getCollectionNames`, `getCollectionInfos`, -`createCollection`, `dropDatabase`, `stats`, `version`, `serverStatus`, `hostInfo`, `currentOp`, -`killOp`, `runCommand`, `adminCommand`. `use `, `show dbs` and `show collections` work as +`createCollection`, `createView`, `dropDatabase`, `stats`, `version`, `serverStatus`, `hostInfo`, +`currentOp`, `killOp`, `runCommand`, `adminCommand`. `use `, `show dbs` and `show collections` work as typed. Anything with no method of its own goes through `db.runCommand({…})`. `Cmd+Shift+F` reformats by nesting depth. Autocomplete offers collections, collection methods, @@ -243,6 +264,10 @@ New connections default to **Disabled**, and the driver has no TLS fallback: **P - A `Code` scope holding an object that opens with `$type`, `$regex` or `$options`, such as `Code("f", {a: {$type: "binData"}})`, fails with `This is not a document MongoDB can read`. List another key of that object first. - A script that loops without touching the database cannot be stopped: JavaScriptCore has no public way to interrupt one. `Cmd+.` stops anything that reads, writes or prints, which covers every query. A script silent for 120 seconds is abandoned and the shell restarts. - Field names that are whole numbers up to 4294967294 (`"0"`, `"12"`) sort ahead of the rest in a document literal, which is what JavaScript does with them. A nested object with such a key after another key, or with a key named `__proto__`, refuses the save when it is duplicated or written whole. Use **Insert Document…** or a query for it. +- An MQL export of a view holds the view's documents, not the view, so restoring the file creates a collection of that name. Drop that collection and run the view's **Show DDL** text to get the view back. +- A time-series collection takes inserts and deletes from the grid, but refuses an edited cell and a rename, and the server's error is shown. Change its documents from a query tab with `updateMany` filtered on the meta field. +- A filter or validator with a regular expression under `$regex`, such as `{email: {$regex: /@/i}}`, is refused as a document MongoDB cannot read. Write `{email: /@/i}` or `{email: {$regex: "@", $options: "i"}}` instead. **Show DDL** writes such a validator the way the server holds it, which runs in mongosh but not in a query tab. +- A DBPointer, an `undefined`, or a date more than 100 million days from 1 January 1970 has no mongosh spelling, so **Show DDL** keeps its Extended JSON wrapper. A query tab runs that text; mongosh does not. ## Troubleshooting diff --git a/project.yml b/project.yml index 0cbb4bdb3..f5af54a5a 100644 --- a/project.yml +++ b/project.yml @@ -520,11 +520,17 @@ targets: - Plugins/MongoDBDriverPlugin/MongoDBFilterClause.swift - Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift - Plugins/MongoDBDriverPlugin/MongoDBFindLimitPolicy.swift + - Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift + - Plugins/MongoDBDriverPlugin/MongoDBJsonLayout.swift - Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift - Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift + - Plugins/MongoDBDriverPlugin/MongoDBNamespaceEntry.swift + - Plugins/MongoDBDriverPlugin/MongoDBObjectStatements.swift - Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift - Plugins/MongoDBDriverPlugin/MongoDBRawFilterNormalizer.swift - Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift + - Plugins/MongoDBDriverPlugin/MongoDBShellLiteral.swift + - Plugins/MongoDBDriverPlugin/MongoDBShellText.swift - Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift - Plugins/MongoDBDriverPlugin/MongoDBUpdateDocument.swift - Plugins/MongoDBDriverPlugin/MongoDBWriteRefusal.swift