diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f22068cc..85aedfc1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -524,6 +524,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ER diagram opening at 100% instead of fitted, then losing its zoom the first time you came back to its tab. - Diagram Fit to Window stopping a scroll bar's width short when scroll bars are always shown. - No Executing indicator or Stop button for a query tab with no result grid, in Output mode or on a query plan. +- MongoDB collections could not be created from **New Table…**. (#3131) +- A new or empty MongoDB collection showing only `_id` instead of the fields its validator declares. +- MongoDB edits that stored dates and ObjectIds as text, rounded integers past 2^53, or missed a string `_id` that looks numeric. +- **New Table…** offered on databases that cannot create a table, such as Redis and Kafka. - Executing indicator and Stop button carried over for a moment onto the query tab switched to. ### Security @@ -552,6 +556,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server statements written after another without a semicolon skipped Safe Mode, on Mac, iPhone and iPad. - SQL Server connections set to Required (skip verify) not encrypted past the login. - Inline suggestions sending the query and table columns to the AI provider on Ask Each Time and Never connections. +- Stored MongoDB values and collection names that ran as shell code when a row was edited, duplicated or restored. ## [0.75.0] - 2026-09-18 diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index 3980645f3f..66462cb345 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -371,6 +371,22 @@ struct BsonDocumentFlattener { // MARK: - Type Inference + /// The kind every non-null value of a field shares, or nil when they differ or there are none. + static func uniformKind( + of field: String, + in documents: [[String: Any]], + representation: MongoDBUuidRepresentation + ) -> BsonValueKind? { + var shared: BsonValueKind? + for doc in documents { + guard let value = doc[field], !(value is NSNull) else { continue } + let kind = valueKind(for: value, representation: representation) + if let shared, shared != kind { return nil } + shared = kind + } + return shared + } + private static func inferValueKind( for field: String, in documents: [[String: Any]], diff --git a/Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift b/Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift new file mode 100644 index 0000000000..21d80c4627 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift @@ -0,0 +1,173 @@ +import Foundation +import TableProPluginKit + +enum MongoDBCollectionDDL { + static let idField = "_id" + static let unsupportedIndexTypes: Set = ["GIN", "GIST", "BRIN", "SPGIST"] + + /// A collection's fields exist on the server only as a `$jsonSchema` validator. Leaving + /// `validationLevel` and `validationAction` out keeps the server's own defaults, strict and + /// error, which is also what Compass and Studio 3T start from. + static func createCollectionStatement(for definition: PluginCreateTableDefinition) -> String { + let name = MongoScriptJson.jsonString(definition.tableName) + let fields = definition.columns.filter { !$0.name.isEmpty } + guard fields.contains(where: { $0.name != idField }) else { + return "db.createCollection(\(name))" + } + + let properties = fields.map { field in + " \(MongoScriptJson.jsonString(field.name)): {\"bsonType\": \(bsonTypeJson(for: field))}" + } + var schema = [" \"bsonType\": \"object\""] + let required = fields.filter { !$0.isNullable && $0.name != idField }.map(\.name) + if !required.isEmpty { + schema.append(" \"required\": [\(required.map(MongoScriptJson.jsonString).joined(separator: ", "))]") + } + schema.append(" \"properties\": {\n\(properties.joined(separator: ",\n"))\n }") + + return """ + db.createCollection(\(name), { + "validator": { + "$jsonSchema": { + \(schema.joined(separator: ",\n")) + } + } + }) + """ + } + + static func createIndexStatement(collection: String, index: PluginIndexDefinition) -> String? { + guard !index.columns.isEmpty, let keyValue = indexKeyValue(for: index.indexType) else { return nil } + let keys = index.columns + .map { "\(MongoScriptJson.jsonString($0)): \(keyValue)" } + .joined(separator: ", ") + var options: [String] = [] + if !index.name.isEmpty { + options.append("\"name\": \(MongoScriptJson.jsonString(index.name))") + } + if index.isUnique { + options.append("\"unique\": true") + } + let accessor = MongoCollectionAccessor.expression(for: collection) + guard !options.isEmpty else { return "\(accessor).createIndex({\(keys)})" } + return "\(accessor).createIndex({\(keys)}, {\(options.joined(separator: ", "))})" + } + + static func refusal(for operation: PluginSchemaOperation) -> String? { + switch operation { + case .addColumn(let column): + return columnRefusal(column) + case .addIndex(let index), .modifyIndex(_, let index): + return indexRefusal(index) + case .renameCheckConstraint, .dropIndex: + return nil + @unknown default: + return nil + } + } + + // MARK: - Columns + + private static func bsonTypeJson(for field: PluginColumnDefinition) -> String { + if field.name == idField { + return "\"objectId\"" + } + let declared = MongoDBBsonType.alias(forEditorType: field.dataType) + ?? field.dataType.trimmingCharacters(in: .whitespaces) + guard field.isNullable, declared != "null" else { + return MongoScriptJson.jsonString(declared) + } + return "[\(MongoScriptJson.jsonString(declared)), \"null\"]" + } + + private static func columnRefusal(_ column: PluginColumnDefinition) -> String? { + if column.name.hasPrefix("$") || column.name.contains(".") { + return String( + format: String(localized: "MongoDB cannot address a field named %@. A field name cannot start with $ or contain a dot."), + column.name + ) + } + if isReorderedByTheShell(column.name) { + return String( + format: String(localized: "The shell would reorder or drop a field named %@. Choose another name."), + column.name + ) + } + guard let alias = MongoDBBsonType.alias(forEditorType: column.dataType) else { + return String( + format: String(localized: "%1$@ has type %2$@, which MongoDB does not have. Choose a type from the list."), + column.name, column.dataType + ) + } + if column.name == idField { + guard alias == "objectId" else { + return String(localized: "_id must be an objectId. MongoDB generates it for every new document.") + } + return nil + } + if column.isPrimaryKey { + return String( + format: String(localized: "%@ cannot be the primary key. MongoDB keys every document by _id."), + column.name + ) + } + return nil + } + + /// The statement is a JavaScript object literal, and JavaScript lists integer-like keys first in + /// ascending order and reads `__proto__` as the prototype rather than as a key, so neither kind of + /// name reaches the server where it was written. + private static func isReorderedByTheShell(_ name: String) -> Bool { + if name == "__proto__" { return true } + guard !name.isEmpty, name.utf8.allSatisfy({ (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains($0) }) else { + return false + } + return name == "0" || !name.hasPrefix("0") + } + + // MARK: - Indexes + + private static func indexKeyValue(for indexType: String?) -> String? { + switch indexType?.uppercased() ?? "" { + case "", "BTREE": return "1" + case "HASH": return "\"hashed\"" + case "FULLTEXT": return "\"text\"" + case "SPATIAL": return "\"2dsphere\"" + default: return nil + } + } + + private static func indexRefusal(_ index: PluginIndexDefinition) -> String? { + let label = index.name.isEmpty ? index.columns.joined(separator: ", ") : index.name + let usesSQLOnlyFeature = !(index.whereClause ?? "").isEmpty + || !(index.expressions ?? []).isEmpty + || !(index.includedColumns ?? []).isEmpty + || !(index.columnPrefixes ?? [:]).isEmpty + if usesSQLOnlyFeature { + return String( + format: String(localized: "Index %@ has a WHERE clause, an expression, included columns or a prefix length. MongoDB indexes take none of these."), + label + ) + } + guard let keyValue = indexKeyValue(for: index.indexType) else { + return String( + format: String(localized: "MongoDB has no %@ index."), + index.indexType ?? "" + ) + } + if keyValue == "1", index.columns == [idField] { + return String(localized: "MongoDB already indexes _id.") + } + if keyValue == "\"text\"", index.isUnique { + return String(localized: "A text index cannot be unique.") + } + guard keyValue == "\"hashed\"" else { return nil } + if index.isUnique { + return String(localized: "A hashed index cannot be unique.") + } + if index.columns.count > 1 { + return String(localized: "A hashed index takes one field.") + } + return nil + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBCollectionSchema.swift b/Plugins/MongoDBDriverPlugin/MongoDBCollectionSchema.swift new file mode 100644 index 0000000000..a6a4604d3a --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBCollectionSchema.swift @@ -0,0 +1,187 @@ +import Foundation +import TableProPluginKit + +enum MongoDBBsonType { + static let aliases: [String] = [ + "double", "string", "object", "array", "binData", "objectId", "bool", "date", "null", + "regex", "javascript", "int", "timestamp", "long", "decimal", "minKey", "maxKey", "number" + ] + + /// `$jsonSchema` matches `bsonType` aliases case-sensitively, measured on 7.0: `ObjectId` and + /// `String` are refused as unknown aliases, so a spelling is only ever matched here, never + /// passed through. + static func alias(forEditorType editorType: String) -> String? { + let trimmed = editorType.trimmingCharacters(in: .whitespaces) + return aliases.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame } + } + + static func alias(forJsonSchemaType type: String) -> String? { + switch type { + case "string", "object", "array", "null", "number": return type + case "boolean": return "bool" + default: return nil + } + } + + /// A kind for the writer and the filter to type values against, or nil when the alias leaves + /// the value's type open. Binary data has no subtype in a schema, and a column's UUID decoding + /// is decided once from its documents, so a declaration never picks it. + static func valueKind(forAlias alias: String) -> BsonValueKind? { + switch alias { + case "objectId": return .objectId + case "string": return .string + case "int": return .int32 + case "long": return .int64 + case "double": return .double + case "decimal": return .decimal128 + case "date": return .date + case "bool": return .boolean + case "array": return .array + case "object": return .document + default: return nil + } + } +} + +struct MongoDBDeclaredField: Equatable, Sendable { + let name: String + let bsonTypes: [String] + let isRequired: Bool + let allowedValues: [String]? + + var valueKind: BsonValueKind? { + let valueTypes = bsonTypes.filter { $0 != "null" } + guard valueTypes.count == 1, let only = valueTypes.first else { return nil } + return MongoDBBsonType.valueKind(forAlias: only) + } + + func columnTypeName(representation: MongoDBUuidRepresentation) -> String { + if let valueKind { + return BsonDocumentFlattener.typeName(for: valueKind, representation: representation) + } + let valueTypes = bsonTypes.filter { $0 != "null" } + if valueTypes == ["binData"] { + return BsonDocumentFlattener.typeName(for: .binary(subtype: 0), representation: representation) + } + return valueTypes.first ?? "null" + } +} + +/// The columns a collection presents: the fields its documents hold, then the fields its +/// validator declares that no document in hand holds yet. A new collection has no documents, and +/// sampling alone showed it as `_id` and nothing else. +enum MongoDBCollectionShape { + static func emptyCollectionColumns(declaring schema: MongoDBCollectionSchema) -> [String] { + [MongoDBCollectionDDL.idField] + schema.fields.map(\.name).filter { $0 != MongoDBCollectionDDL.idField } + } + + static func declaredColumnsMissing(from sampled: [String], schema: MongoDBCollectionSchema) -> [String] { + let present = Set(sampled) + return schema.fields.map(\.name).filter { !present.contains($0) } + } +} + +struct MongoDBCollectionSchema: Equatable, Sendable { + let fields: [MongoDBDeclaredField] + + static let empty = MongoDBCollectionSchema(fields: []) + + var isEmpty: Bool { fields.isEmpty } + + func field(named name: String) -> MongoDBDeclaredField? { + fields.first { $0.name == name } + } + + var valueKinds: [String: BsonValueKind] { + var kinds: [String: BsonValueKind] = [:] + for field in fields { + guard let kind = field.valueKind else { continue } + kinds[field.name] = kind + } + return kinds + } + + var allowedValues: [String: [String]] { + var values: [String: [String]] = [:] + for field in fields { + guard let allowed = field.allowedValues else { continue } + values[field.name] = allowed + } + return values + } + + /// Bounded on the server, because the read runs after a find on the session driver and a + /// stalled catalog would hold every later statement on the connection behind it. + static let listCollectionsTimeoutMS = 5_000 + + static func listCollectionsCommand(for collection: String) -> String { + "{\"listCollections\": 1, \"filter\": {\"name\": \(MongoScriptJson.jsonString(collection))}, " + + "\"maxTimeMS\": \(listCollectionsTimeoutMS)}" + } + + /// Reads a `listCollections` reply's first collection. The reply is walked as text because the + /// order of `properties` is the order the fields were declared in, and decoding into a + /// dictionary loses it. + static func parse(listCollectionsReply reply: String) -> MongoDBCollectionSchema { + guard let cursor = MongoScriptJson.member(of: reply, key: "cursor"), + let batch = MongoScriptJson.member(of: cursor, key: "firstBatch"), + let collection = MongoScriptJson.topLevelElements(batch).first, + let options = MongoScriptJson.member(of: collection, key: "options"), + let validator = MongoScriptJson.member(of: options, key: "validator"), + let jsonSchema = MongoScriptJson.member(of: validator, key: "$jsonSchema") else { + return .empty + } + return parse(jsonSchema: jsonSchema) + } + + static func parse(jsonSchema: String) -> MongoDBCollectionSchema { + guard let schema = decodeObject(jsonSchema), + let properties = schema["properties"] as? [String: Any], + let propertiesText = MongoScriptJson.member(of: jsonSchema, key: "properties") else { + return .empty + } + let required = Set(schema["required"] as? [String] ?? []) + let orderedNames = orderedKeys(of: propertiesText, in: properties) + + let fields = orderedNames.compactMap { name -> MongoDBDeclaredField? in + guard let spec = properties[name] as? [String: Any] else { return nil } + return MongoDBDeclaredField( + name: name, + bsonTypes: declaredTypes(in: spec), + isRequired: required.contains(name), + allowedValues: stringEnum(spec["enum"]) + ) + } + return MongoDBCollectionSchema(fields: fields) + } + + private static func orderedKeys(of objectText: String, in decoded: [String: Any]) -> [String] { + var seen = Set() + let textual = MongoScriptJson.members(of: objectText).map(\.key).filter { + decoded[$0] != nil && seen.insert($0).inserted + } + return textual + decoded.keys.filter { !seen.contains($0) }.sorted() + } + + /// An `enum` of strings with no type of its own still only admits strings, and typing the field + /// as one is what keeps `"1"` from being written back as the number 1 and refused. + private static func declaredTypes(in spec: [String: Any]) -> [String] { + if let single = spec["bsonType"] as? String { return [single] } + if let many = spec["bsonType"] as? [String] { return many } + if let single = spec["type"] as? String { return [MongoDBBsonType.alias(forJsonSchemaType: single)].compactMap { $0 } } + if let many = spec["type"] as? [String] { return many.compactMap(MongoDBBsonType.alias(forJsonSchemaType:)) } + if stringEnum(spec["enum"]) != nil { return ["string"] } + return [] + } + + private static func stringEnum(_ value: Any?) -> [String]? { + guard let array = value as? [Any], !array.isEmpty, + array.allSatisfy({ $0 is String }) else { return nil } + return array.compactMap { $0 as? String } + } + + private static func decodeObject(_ text: String) -> [String: Any]? { + guard let data = text.data(using: .utf8) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 57ff88a37d..310cee6cd3 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -552,6 +552,20 @@ final class MongoDBConnection: @unchecked Sendable { #endif } + /// The reply as canonical Extended JSON text, members in the order the server sent them. + func runCommandJson(_ command: String, database: String? = nil) async throws -> String { + #if canImport(CLibMongoc) + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected + } + return QueueTransfer(value: try scriptRunCommand(client: client, command: command, database: database)) + }.value + #else + throw MongoDBError.libmongocUnavailable + #endif + } + // MARK: - Collection Operations func find( diff --git a/Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift b/Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift index 9ad0f72307..821d74b632 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift @@ -63,7 +63,17 @@ enum MongoDBFilterValue { return "{\"$date\": {\"$numberLong\": \"\(millis)\"}}" } - static func epochMilliseconds(_ value: String) -> Int64? { + /// A date the grid writes. The date picker fills an empty cell with the user's own wall clock + /// and no zone, so zone-less text is read in the local zone here, where a filter reads it as + /// UTC. A bare number is a year or a count far more often than epoch milliseconds, so it is + /// not read as a date. + static func writableDateJson(_ value: String) -> String? { + guard Int64(value.trimmingCharacters(in: .whitespaces)) == nil, + let millis = epochMilliseconds(value, plainTimeZone: .current) else { return nil } + return "{\"$date\": {\"$numberLong\": \"\(millis)\"}}" + } + + static func epochMilliseconds(_ value: String, plainTimeZone: TimeZone = .gmt) -> Int64? { let trimmed = value.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } @@ -77,7 +87,7 @@ enum MongoDBFilterValue { } } for format in plainFormats { - if let date = plainFormatter(format).date(from: trimmed) { + if let date = plainFormatter(format, timeZone: plainTimeZone).date(from: trimmed) { return Int64((date.timeIntervalSince1970 * 1000).rounded()) } } @@ -98,10 +108,10 @@ enum MongoDBFilterValue { "yyyy-MM-dd", ] - private static func plainFormatter(_ format: String) -> DateFormatter { + private static func plainFormatter(_ format: String, timeZone: TimeZone) -> DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(identifier: "UTC") + formatter.timeZone = timeZone formatter.dateFormat = format return formatter } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 3c924c4bae..5c6da17ebe 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -18,6 +18,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let rawFilterNormalizer = MongoDBRawFilterNormalizer() private var columnKindsByCollection: [String: [String: BsonValueKind]] = [:] private var fieldPathKindsByCollection: [String: [String: BsonValueKind]] = [:] + private var declaredSchemasByCollection: [String: MongoDBCollectionSchema] = [:] + private var identityKindsByCollection: [String: BsonValueKind] = [:] private static let logger = Logger(subsystem: "com.TablePro", category: "MongoDBPluginDriver") @@ -203,14 +205,19 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { valueCeiling: ceiling ) if let switched = outcome.databaseSwitch { currentDb = switched } + let declared = try await declaredColumns(for: outcome) return capToRowCap( MongoScriptResultBuilder.result( for: outcome, startTime: startTime, + emptyColumns: MongoDBCollectionShape.emptyCollectionColumns(declaring: declared).map { + (name: $0, typeName: declaredTypeName(of: $0, in: declared)) + }, documents: { documents, collection, isTruncated in self.buildPluginResult( from: documents, startTime: startTime, - isTruncated: isTruncated, collection: collection + isTruncated: isTruncated, collection: collection, + declared: declared ) } ), @@ -224,6 +231,35 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + /// The fields the collection declares, for a find that returned whole documents; empty, and not + /// read at all, for a projection, a pipeline or anything else. Read on the session driver because that is the one + /// whose writer and filters use it: `fetchColumns` runs on a pooled driver whose caches this + /// one never sees. Read again on a first page, so a refresh picks up a changed validator, and + /// reused while paging. + private func declaredColumns(for outcome: MongoScriptStatementResult) async throws -> MongoDBCollectionSchema { + guard outcome.producedDocuments, + let find = outcome.find, find.returnsWholeDocuments, find.database == currentDb, + let collection = outcome.collection, !collection.isEmpty, + let conn = mongoConnection else { + return .empty + } + let key = columnKindKey(collection) + let cached = columnKindLock.withLock { declaredSchemasByCollection[key] } + let schema: MongoDBCollectionSchema + if find.skip > 0, let cached { + schema = cached + } else { + schema = try await declaredSchema(of: collection, conn: conn) + columnKindLock.withLock { declaredSchemasByCollection[key] = schema } + } + return schema + } + + private func declaredTypeName(of column: String, in schema: MongoDBCollectionSchema) -> String { + schema.field(named: column)?.columnTypeName(representation: uuidRepresentation) + ?? BsonDocumentFlattener.typeName(for: .objectId, representation: uuidRepresentation) + } + private func capToRowCap(_ result: PluginQueryResult, rowCap: Int?) -> PluginQueryResult { guard let rowCap, MongoDBFindLimitPolicy.isTruncated(rowCount: result.rows.count, rowCap: rowCap) else { return result @@ -293,66 +329,61 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { filter: "{}", sort: nil, projection: nil, skip: 0, limit: MongoStreamProjection.sampleSize ).docs - let enumMap = (try? await fetchJsonSchemaEnums(conn: conn, table: table)) ?? [:] + let schema = try await declaredSchema(of: table, conn: conn) if docs.isEmpty { - return [ - PluginColumnInfo( - name: "_id", dataType: "ObjectId", isNullable: false, isPrimaryKey: true, - defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil - ) - ] + return MongoDBCollectionShape.emptyCollectionColumns(declaring: schema) + .map { declaredColumnInfo($0, schema: schema) } } - let columns = BsonDocumentFlattener.unionColumns(from: docs) + let sampled = BsonDocumentFlattener.unionColumns(from: docs) let kinds = BsonDocumentFlattener.columnKinds( - for: columns, documents: docs, representation: uuidRepresentation + for: sampled, documents: docs, representation: uuidRepresentation ) - rememberColumnKinds(kinds, for: columns, collection: table) - - return columns.enumerated().map { index, name in - let typeName = BsonDocumentFlattener.typeName( - for: kinds[index], representation: uuidRepresentation - ) - return PluginColumnInfo( - name: name, dataType: typeName, isNullable: name != "_id", isPrimaryKey: name == "_id", + rememberColumnKinds(kinds, for: sampled, collection: table) + + let sampledColumns = sampled.enumerated().map { index, name in + PluginColumnInfo( + name: name, + dataType: BsonDocumentFlattener.typeName(for: kinds[index], representation: uuidRepresentation), + isNullable: name != MongoDBCollectionDDL.idField && schema.field(named: name)?.isRequired != true, + isPrimaryKey: name == MongoDBCollectionDDL.idField, defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil, - allowedValues: enumMap[name] + allowedValues: schema.allowedValues[name] ) } + let unseen = MongoDBCollectionShape.declaredColumnsMissing(from: sampled, schema: schema) + return sampledColumns + unseen.map { declaredColumnInfo($0, schema: schema) } } - private func fetchJsonSchemaEnums(conn: MongoDBConnection, table: String) async throws -> [String: [String]] { - let escaped = escapeJsonString(table) - let result = try await conn.runCommand( - "{\"listCollections\": 1, \"filter\": {\"name\": \"\(escaped)\"}}", - database: currentDb + private func declaredColumnInfo(_ name: String, schema: MongoDBCollectionSchema) -> PluginColumnInfo { + let field = schema.field(named: name) + let isKey = name == MongoDBCollectionDDL.idField + return PluginColumnInfo( + name: name, + dataType: field?.columnTypeName(representation: uuidRepresentation) + ?? BsonDocumentFlattener.typeName(for: .objectId, representation: uuidRepresentation), + isNullable: !isKey && field?.isRequired != true, + isPrimaryKey: isKey, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil, + allowedValues: schema.allowedValues[name] ) - guard 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], - let validator = options["validator"] as? [String: Any], - let jsonSchema = validator["$jsonSchema"] as? [String: Any], - let properties = jsonSchema["properties"] as? [String: Any] - else { return [:] } - - var map: [String: [String]] = [:] - for (colName, spec) in properties { - guard let specDict = spec as? [String: Any] else { continue } - if let enumValues = extractStringEnum(specDict["enum"]) { - map[colName] = enumValues - } - } - return map } - private func extractStringEnum(_ value: Any?) -> [String]? { - guard let array = value as? [Any], !array.isEmpty else { return nil } - guard array.allSatisfy({ $0 is String }) else { return nil } - let strings = array.compactMap { $0 as? String } - return strings.isEmpty ? nil : strings + /// A user whose role can `find` but not `listCollections` still browses, with no declared + /// fields, so a failed read is an empty schema rather than an error. A Stop is not a failure: + /// swallowing it returned the page as though nothing had been cancelled. + private func declaredSchema(of collection: String, conn: MongoDBConnection) async throws -> MongoDBCollectionSchema { + do { + let reply = try await conn.runCommandJson( + MongoDBCollectionSchema.listCollectionsCommand(for: collection), database: currentDb + ) + return MongoDBCollectionSchema.parse(listCollectionsReply: reply) + } catch is CancellationError { + throw CancellationError() + } catch { + return .empty + } } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { @@ -672,6 +703,22 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ["db.getCollection(\"\(escapeJsonString(table))\").deleteMany({})"] } + // MARK: - Collection Creation + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + MongoDBCollectionDDL.createCollectionStatement(for: definition) + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + MongoDBCollectionDDL.createIndexStatement(collection: table, index: index) + } + + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { + MongoDBCollectionDDL.refusal(for: operation) + } + + var unsupportedIndexTypes: Set { MongoDBCollectionDDL.unsupportedIndexTypes } + func dropDatabase(name: String) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected @@ -747,7 +794,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { insertedRowIndices: Set ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = MongoDBStatementGenerator( - collectionName: table, columns: columns, columnKinds: columnKinds(for: table) + collectionName: table, columns: columns, columnKinds: columnKinds(for: table), + declaredKinds: declaredKinds(for: table), identityKind: identityKind(for: table) ) return generator.generateStatements( from: changes, insertedRowData: insertedRowData, @@ -763,7 +811,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { rows: [[PluginCellValue]] ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = MongoDBStatementGenerator( - collectionName: table, columns: columns, columnKinds: columnKinds(for: table) + collectionName: table, columns: columns, columnKinds: columnKinds(for: table), + declaredKinds: declaredKinds(for: table), identityKind: identityKind(for: table) ) return generator.generateRestore(rows: rows) } @@ -857,7 +906,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { from documents: [[String: Any]], startTime: Date, isTruncated: Bool = false, - collection: String = "" + collection: String = "", + declared: MongoDBCollectionSchema = .empty ) -> PluginQueryResult { if documents.isEmpty { return PluginQueryResult( @@ -867,13 +917,18 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - let columns = BsonDocumentFlattener.unionColumns(from: documents) - let kinds = BsonDocumentFlattener.columnKinds( - for: columns, documents: documents, representation: uuidRepresentation + let sampled = BsonDocumentFlattener.unionColumns(from: documents) + let sampledKinds = BsonDocumentFlattener.columnKinds( + for: sampled, documents: documents, representation: uuidRepresentation ) - rememberColumnKinds(kinds, for: columns, collection: collection) + rememberColumnKinds(sampledKinds, for: sampled, collection: collection) + rememberIdentityKind(of: documents, collection: collection) rememberFieldPathKinds(from: documents, collection: collection) - let typeNames = kinds.map { BsonDocumentFlattener.typeName(for: $0, representation: uuidRepresentation) } + let unseen = MongoDBCollectionShape.declaredColumnsMissing(from: sampled, schema: declared) + let columns = sampled + unseen + let kinds = sampledKinds + unseen.map { declared.field(named: $0)?.valueKind ?? .null } + let typeNames = sampledKinds.map { BsonDocumentFlattener.typeName(for: $0, representation: uuidRepresentation) } + + unseen.map { declaredTypeName(of: $0, in: declared) } let rows = BsonDocumentFlattener.flatten( documents: documents, columns: columns, kinds: kinds, representation: uuidRepresentation ) @@ -891,18 +946,18 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) - for char in value { - switch char { - case "\"": result += "\\\"" + for scalar in value.unicodeScalars { + switch scalar { case "\\": result += "\\\\" + case "\"": result += "\\\"" case "\n": result += "\\n" case "\r": result += "\\r" case "\t": result += "\\t" default: - if let ascii = char.asciiValue, ascii < 0x20 { - result += String(format: "\\u%04x", ascii) + if scalar.value < 0x20 { + result += String(format: "\\u%04x", scalar.value) } else { - result.append(char) + result.unicodeScalars.append(scalar) } } } @@ -934,6 +989,25 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return columnKindLock.withLock { columnKindsByCollection[key] ?? [:] } } + private func rememberIdentityKind(of documents: [[String: Any]], collection: String) { + guard !collection.isEmpty else { return } + let kind = BsonDocumentFlattener.uniformKind( + of: MongoDBCollectionDDL.idField, in: documents, representation: uuidRepresentation + ) + let key = columnKindKey(collection) + columnKindLock.withLock { identityKindsByCollection[key] = kind } + } + + private func identityKind(for collection: String) -> BsonValueKind? { + let key = columnKindKey(collection) + return columnKindLock.withLock { identityKindsByCollection[key] } + } + + private func declaredKinds(for collection: String) -> [String: BsonValueKind] { + let key = columnKindKey(collection) + return columnKindLock.withLock { declaredSchemasByCollection[key]?.valueKinds ?? [:] } + } + /// Recorded from the documents a browse already fetched, on the session driver that will /// build the filter. Sampling through `sampleFieldPaths` cannot do it: that call is routed /// through `MetadataConnectionPool`, so it lands on a different driver instance whose cache @@ -953,7 +1027,9 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func filterKinds(for collection: String) -> [String: BsonValueKind] { let key = columnKindKey(collection) return columnKindLock.withLock { - (fieldPathKindsByCollection[key] ?? [:]).merging(columnKindsByCollection[key] ?? [:]) { _, top in top } + (declaredSchemasByCollection[key]?.valueKinds ?? [:]) + .merging(fieldPathKindsByCollection[key] ?? [:]) { _, sampled in sampled } + .merging(columnKindsByCollection[key] ?? [:]) { _, top in top } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index 753b3f9256..0d5b1bad3a 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -380,18 +380,18 @@ struct MongoDBQueryBuilder { static func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) - for char in value { - switch char { + for scalar in value.unicodeScalars { + switch scalar { case "\\": result += "\\\\" case "\"": result += "\\\"" case "\n": result += "\\n" case "\r": result += "\\r" case "\t": result += "\\t" default: - if let ascii = char.asciiValue, ascii < 0x20 { - result += String(format: "\\u%04X", ascii) + if scalar.value < 0x20 { + result += String(format: "\\u%04X", scalar.value) } else { - result.append(char) + result.unicodeScalars.append(scalar) } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index 6c0a4bb328..90bb05085d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift @@ -17,6 +17,13 @@ struct MongoDBStatementGenerator { let collectionName: String let columns: [String] var columnKinds: [String: BsonValueKind] = [:] + /// Kinds the collection's validator declares. The server rejects any other type for these + /// fields, so they outrank what the sampled documents happen to hold. + var declaredKinds: [String: BsonValueKind] = [:] + /// The kind every sampled `_id` shares, or nil when they differ. An `_id` filter has to carry the + /// row's own type, and a majority kind would quote an ObjectId in a mostly-string collection and + /// match nothing. + var identityKind: BsonValueKind? private var collectionAccessor: String { MongoCollectionAccessor.expression(for: collectionName) @@ -233,19 +240,37 @@ struct MongoDBStatementGenerator { "{\"_id\": \(idValueJson(idValue))}" } + /// An `_id` is matched by value and type together, so a string `_id` of `1001` written as a + /// number matches no document. The column's own kind decides when it is known; the text's + /// shape is only the fallback. private func idValueJson(_ idValue: String) -> String { if let binary = MongoDBUuidCodec.extendedJsonFromWrapper(idValue) { return binary } + let idKind = declaredKinds["_id"] ?? identityKind + switch idKind { + case .string: + return "\"\(escapeJsonString(idValue))\"" + case .objectId, .int32, .int64, .double, .decimal128, .date: + if let typed = typedJson(idValue, kind: idKind) { return typed } + case .boolean: + if idValue == "true" || idValue == "false" { return idValue } + default: + break + } if isObjectIdString(idValue) { return "{\"$oid\": \"\(idValue)\"}" } if Int64(idValue) != nil { - return idValue + return integerJson(idValue) } return "\"\(escapeJsonString(idValue))\"" } + private func kind(of field: String) -> BsonValueKind? { + declaredKinds[field] ?? columnKinds[field] + } + /// Check if a string looks like a MongoDB ObjectId (24 hex characters) private func isObjectIdString(_ value: String) -> Bool { let nsValue = value as NSString @@ -262,69 +287,96 @@ struct MongoDBStatementGenerator { ) return nil } - entries.append("\"\(escapeJsonString(key))\": \(jsonValue(for: value, kind: columnKinds[key]))") + entries.append("\"\(escapeJsonString(key))\": \(jsonValue(for: value, field: key))") } return "{\(entries.joined(separator: ", "))}" } - /// Convert a string value to its JSON representation (auto-detect type) - private func jsonValue(for value: String, kind: BsonValueKind? = nil) -> String { - if value == "true" || value == "false" { - return value + /// A cell's text as the value it stands for, in the field's type when that type is known. + /// + /// The statement is JavaScript the shell evaluates, so text is only ever pasted in when it is + /// strict JSON. A stored string that merely starts with `[` and ends with `]` would otherwise run + /// as code the moment its row is duplicated or its delete is undone. + private func jsonValue(for value: String, field: String) -> String { + if declaredKinds[field] == .string { + return "\"\(escapeJsonString(value))\"" } - if value == "null" { - return "null" + if value == "true" || value == "false" || value == "null" { + return value } - if kind == .decimal128, NumberText.isJSONNumberLiteral(value) { - return "{\"$numberDecimal\": \"\(escapeJsonString(value))\"}" + if let typed = typedJson(value, kind: kind(of: field)) { + return typed } if MongoDBJsonNumber.isValid(value) { - return typedNumberJson(value, kind: kind) + return Int64(value) != nil ? integerJson(value) : value } if let binary = MongoDBUuidCodec.extendedJsonFromWrapper(value) { return binary } - // JSON object or array - if (value.hasPrefix("{") && value.hasSuffix("}")) || - (value.hasPrefix("[") && value.hasSuffix("]")) { + if isStrictJsonContainer(value) { return value } return "\"\(escapeJsonString(value))\"" } /// A bare JSON number is stored as int32 or double, which silently retypes a column that - /// holds int64 or decimal128. Extended JSON is the only way to keep the original type. - private func typedNumberJson(_ value: String, kind: BsonValueKind?) -> String { + /// holds int64 or decimal128, and a date or an ObjectId written as its text is a string. + /// Extended JSON is the only way to keep the original type. + private func typedJson(_ value: String, kind: BsonValueKind?) -> String? { switch kind { + case .date: + return MongoDBFilterValue.writableDateJson(value) + case .objectId: + return MongoDBFilterValue.objectIdJson(value) case .decimal128: + guard NumberText.isJSONNumberLiteral(value) else { return nil } return "{\"$numberDecimal\": \"\(escapeJsonString(value))\"}" case .int64: - guard Int64(value) != nil else { return value } + guard Int64(value) != nil else { return nil } return "{\"$numberLong\": \"\(escapeJsonString(value))\"}" case .double: - guard let parsed = Double(value), parsed.isFinite else { return value } + guard MongoDBJsonNumber.isValid(value), let parsed = Double(value), parsed.isFinite else { return nil } return "{\"$numberDouble\": \"\(escapeJsonString(value))\"}" + case .int32: + guard let parsed = Int32(value) else { return nil } + return String(parsed) default: - return value + return nil } } + /// JavaScript numbers are doubles, so an integer past 2^53 written bare reaches the server + /// already rounded. + private func integerJson(_ value: String) -> String { + guard let parsed = Int64(value), parsed.magnitude > Self.largestExactDouble else { return value } + return "{\"$numberLong\": \"\(value)\"}" + } + + private static let largestExactDouble: UInt64 = 1 << 53 + + private func isStrictJsonContainer(_ value: String) -> Bool { + guard (value.hasPrefix("{") && value.hasSuffix("}")) || (value.hasPrefix("[") && value.hasSuffix("]")), + let data = value.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) else { return false } + return parsed is [String: Any] || parsed is [Any] + } + /// Escape special characters for JSON strings (handles Unicode control chars U+0000-U+001F) private func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) - for char in value { - switch char { + for scalar in value.unicodeScalars { + switch scalar { case "\\": result += "\\\\" case "\"": result += "\\\"" case "\n": result += "\\n" case "\r": result += "\\r" case "\t": result += "\\t" default: - if let ascii = char.asciiValue, ascii < 0x20 { - result += String(format: "\\u%04X", ascii) + if scalar.value < 0x20 { + result += String(format: "\\u%04X", scalar.value) } else { - result.append(char) + result.unicodeScalars.append(scalar) } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift index f30e8f68c6..637f5bc8c1 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift @@ -183,9 +183,15 @@ final class MongoScriptHost { return try cursor.remaining { try load($0, ceiling: valueCeiling) } } - func cursorDescription(handle: Int) -> (collection: String, isFind: Bool)? { + func cursorDescription(handle: Int) -> (collection: String, find: MongoScriptFindShape?)? { guard let cursor = try? cursors.cursor(for: handle) else { return nil } - return (cursor.collection, cursor.isFind) + guard cursor.isFind else { return (cursor.collection, nil) } + let shape = MongoScriptFindShape( + database: cursor.database, + returnsWholeDocuments: cursor.options.projection == nil, + skip: cursor.options.skip ?? 0 + ) + return (cursor.collection, shape) } /// The query a cursor stands for, without running it. diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift b/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift index f406ec5d05..781a983114 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptJson.swift @@ -108,25 +108,29 @@ enum MongoScriptJson { } /// Every member of a JSON object, in the order the document carries them, each value as text. + /// + /// The text is walked scalar by scalar, never by `Character`: a Unicode Prepend character joins + /// the `"` or `\` after it into one grapheme cluster, and a scan by cluster then misses the end + /// of a string that libbson escaped correctly. static func members(of objectJson: String) -> [(key: String, value: String)] { - let characters = Array(objectJson) - guard let start = characters.firstIndex(of: "{") else { return [] } + let scalars = Array(objectJson.unicodeScalars) + guard let start = scalars.firstIndex(of: "{") else { return [] } var index = start + 1 var pairs: [(key: String, value: String)] = [] - while index < characters.count { - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] == "\"" else { return pairs } - guard let name = readString(characters, &index) else { return pairs } - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] == ":" else { return pairs } + while index < scalars.count { + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] == "\"" else { return pairs } + guard let name = readString(scalars, &index) else { return pairs } + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] == ":" else { return pairs } index += 1 - skipWhitespace(characters, &index) + skipWhitespace(scalars, &index) let valueStart = index - skipValue(characters, &index) - pairs.append((name, String(characters[valueStart ..< index]).trimmingCharacters(in: .whitespaces))) - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] == "," else { return pairs } + skipValue(scalars, &index) + pairs.append((name, text(scalars[valueStart ..< index]))) + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] == "," else { return pairs } index += 1 } return pairs @@ -134,19 +138,19 @@ enum MongoScriptJson { /// The elements of a JSON array, each as its own text. static func topLevelElements(_ arrayJson: String) -> [String] { - let characters = Array(arrayJson) - guard let start = characters.firstIndex(of: "[") else { return [] } + let scalars = Array(arrayJson.unicodeScalars) + guard let start = scalars.firstIndex(of: "[") else { return [] } var index = start + 1 var elements: [String] = [] - while index < characters.count { - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] != "]" else { break } + while index < scalars.count { + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] != "]" else { break } let elementStart = index - skipValue(characters, &index) - elements.append(String(characters[elementStart ..< index]).trimmingCharacters(in: .whitespaces)) - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] == "," else { break } + skipValue(scalars, &index) + elements.append(text(scalars[elementStart ..< index])) + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] == "," else { break } index += 1 } return elements @@ -154,46 +158,93 @@ enum MongoScriptJson { // MARK: - Scanning - private static func skipWhitespace(_ characters: [Character], _ index: inout Int) { - while index < characters.count, characters[index].isWhitespace { index += 1 } + private static func text(_ slice: ArraySlice) -> String { + var view = String.UnicodeScalarView() + view.append(contentsOf: slice) + return String(view).trimmingCharacters(in: .whitespaces) + } + + private static func isWhitespace(_ scalar: Unicode.Scalar) -> Bool { + scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r" + } + + private static func skipWhitespace(_ scalars: [Unicode.Scalar], _ index: inout Int) { + while index < scalars.count, isWhitespace(scalars[index]) { index += 1 } } - private static func readString(_ characters: [Character], _ index: inout Int) -> String? { - guard index < characters.count, characters[index] == "\"" else { return nil } + private static func readString(_ scalars: [Unicode.Scalar], _ index: inout Int) -> String? { + guard index < scalars.count, scalars[index] == "\"" else { return nil } index += 1 - var value = "" - while index < characters.count { - let character = characters[index] - if character == "\\" { - index += 2 - continue - } + var value = String.UnicodeScalarView() + while index < scalars.count { + let scalar = scalars[index] index += 1 - if character == "\"" { return value } - value.append(character) + switch scalar { + case "\"": + return String(value) + case "\\": + guard let decoded = readEscape(scalars, &index) else { return nil } + value.append(decoded) + default: + value.append(scalar) + } } return nil } - private static func skipValue(_ characters: [Character], _ index: inout Int) { + private static func readEscape(_ scalars: [Unicode.Scalar], _ index: inout Int) -> Unicode.Scalar? { + guard index < scalars.count else { return nil } + let escape = scalars[index] + index += 1 + switch escape { + case "\"", "\\", "/": return escape + case "b": return "\u{08}" + case "f": return "\u{0C}" + case "n": return "\n" + case "r": return "\r" + case "t": return "\t" + case "u": return readUnicodeEscape(scalars, &index) + default: return nil + } + } + + private static func readUnicodeEscape(_ scalars: [Unicode.Scalar], _ index: inout Int) -> Unicode.Scalar? { + guard let unit = readHexUnit(scalars, &index) else { return nil } + guard (0xD800 ... 0xDBFF).contains(unit) else { return Unicode.Scalar(unit) } + guard index + 1 < scalars.count, scalars[index] == "\\", scalars[index + 1] == "u" else { return nil } + index += 2 + guard let low = readHexUnit(scalars, &index), (0xDC00 ... 0xDFFF).contains(low) else { return nil } + return Unicode.Scalar(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00)) + } + + private static func readHexUnit(_ scalars: [Unicode.Scalar], _ index: inout Int) -> UInt32? { + guard index + 4 <= scalars.count else { return nil } + var digits = String.UnicodeScalarView() + digits.append(contentsOf: scalars[index ..< index + 4]) + guard let unit = UInt32(String(digits), radix: 16) else { return nil } + index += 4 + return unit + } + + private static func skipValue(_ scalars: [Unicode.Scalar], _ index: inout Int) { var depth = 0 var inString = false var escaped = false - while index < characters.count { - let character = characters[index] + while index < scalars.count { + let scalar = scalars[index] if escaped { escaped = false index += 1 continue } if inString { - if character == "\\" { escaped = true } - if character == "\"" { inString = false } + if scalar == "\\" { escaped = true } + if scalar == "\"" { inString = false } index += 1 continue } - switch character { + switch scalar { case "\"": inString = true case "{", "[": @@ -207,7 +258,7 @@ enum MongoScriptJson { break } index += 1 - if depth == 0, character == "}" || character == "]" { return } + if depth == 0, scalar == "}" || scalar == "]" { return } } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift index 367f15c520..274b1960e8 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift @@ -10,13 +10,15 @@ enum MongoScriptResultBuilder { static func result( for outcome: MongoScriptStatementResult, startTime: Date, + emptyColumns: [(name: String, typeName: String)] = [(name: "_id", typeName: "ObjectId")], documents build: ([[String: Any]], String, Bool) -> PluginQueryResult ) -> PluginQueryResult { if outcome.producedDocuments, outcome.documents.json.isEmpty { // Zero columns reads as write-success in the result pane, so a query that matched // nothing has to keep its row-producing shape. return PluginQueryResult( - columns: ["_id"], columnTypeNames: ["ObjectId"], rows: [], rowsAffected: 0, + columns: emptyColumns.map(\.name), columnTypeNames: emptyColumns.map(\.typeName), + rows: [], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptRuntime.swift b/Plugins/MongoDBDriverPlugin/MongoScriptRuntime.swift index dcfb993517..95c327bf16 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptRuntime.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptRuntime.swift @@ -3,10 +3,20 @@ import JavaScriptCore import os import TableProPluginKit +/// The find a statement's documents came from. A find with no projection returns whole +/// documents, so its columns can include what the collection declares; a projection or a pipeline +/// chose its own shape. +struct MongoScriptFindShape: Equatable, Sendable { + let database: String + let returnsWholeDocuments: Bool + let skip: Int +} + /// What one statement of a script evaluated to. struct MongoScriptStatementResult: Sendable { var documents = MongoScriptDocumentBatch.empty var collection: String? + var find: MongoScriptFindShape? var scalarRows: [String]? var printedLines: [String] = [] var databaseSwitch: String? @@ -298,7 +308,9 @@ final class MongoScriptRuntime: @unchecked Sendable { case "cursor": guard let handle = payload["handle"] as? Int else { return } result.documents = try engine.host.drain(handle: handle) - result.collection = engine.host.cursorDescription(handle: handle)?.collection + let description = engine.host.cursorDescription(handle: handle) + result.collection = description?.collection + result.find = description?.find result.producedDocuments = true case "array": guard let json = payload["json"] as? String else { return } diff --git a/Plugins/TableProPluginKit/MongoCollectionAccessor.swift b/Plugins/TableProPluginKit/MongoCollectionAccessor.swift index 45d6cad621..c4383d6ac8 100644 --- a/Plugins/TableProPluginKit/MongoCollectionAccessor.swift +++ b/Plugins/TableProPluginKit/MongoCollectionAccessor.swift @@ -28,9 +28,20 @@ public enum MongoCollectionAccessor { name.hasPrefix("__") || databaseMemberNames.contains(name) } + /// Checked byte by byte, because a `Character` is a whole grapheme cluster: a Unicode Prepend + /// letter such as U+0D4E joined to a `(` or `;` answers `isLetter` for the pair, and the name + /// then reached the statement bare, as code. Everything else goes through `getCollection`. private static func isPlainIdentifier(_ name: String) -> Bool { - guard let first = name.first, !first.isNumber else { return false } - return name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } + guard let first = name.utf8.first, !isASCIIDigit(first) else { return false } + return name.utf8.allSatisfy { isASCIILetter($0) || isASCIIDigit($0) || $0 == UInt8(ascii: "_") } + } + + private static func isASCIILetter(_ byte: UInt8) -> Bool { + (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) || (UInt8(ascii: "A") ... UInt8(ascii: "Z")).contains(byte) + } + + private static func isASCIIDigit(_ byte: UInt8) -> Bool { + (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte) } /// Every method mongosh puts on `db`, plus what `Object.prototype` gives any JavaScript value. diff --git a/TablePro/Core/Database/CreateTableEligibility.swift b/TablePro/Core/Database/CreateTableEligibility.swift new file mode 100644 index 0000000000..faffcad27e --- /dev/null +++ b/TablePro/Core/Database/CreateTableEligibility.swift @@ -0,0 +1,40 @@ +// +// CreateTableEligibility.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Whether New Table can do anything on a connection. The Create Table editor turns a draft into +/// statements through the driver's form or its `generateCreateTableSQL`, and an engine with neither +/// used to be offered the editor anyway and refuse only after the grid was filled in. So the driver +/// is asked here, with a one-column probe, the way `DatabaseObjectToolEligibility` asks its hooks. +enum CreateTableEligibility { + static let probeDefinition = PluginCreateTableDefinition( + tableName: "t", + columns: [ + PluginColumnDefinition( + name: "c", + dataType: "TEXT", + isNullable: true, + defaultValue: nil, + isPrimaryKey: false, + autoIncrement: false, + comment: nil, + unsigned: false, + onUpdate: nil, + charset: nil, + collation: nil + ) + ] + ) + + @MainActor + static func canCreateTable(with driver: DatabaseDriver?) -> Bool { + guard let driver else { return false } + if driver.createTableFormSpec(schema: nil) != nil { return true } + guard let adapter = driver as? PluginDriverAdapter else { return false } + return adapter.generateCreateTableSQL(definition: probeDefinition) != nil + } +} diff --git a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index cf5b282f9f..1626b7b891 100644 --- a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -26,6 +26,13 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDataSinkAdapter") + /// Rows go in as SQL `INSERT`s, so an engine with no SQL dialect can take none of them. + static func canWriteRows(into databaseType: DatabaseType) -> Bool { + (try? SQLStatementGenerator( + tableName: "t", columns: [], primaryKeyColumns: [], databaseType: databaseType + )) != nil + } + init( driver: DatabaseDriver, databaseType: DatabaseType, diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index a5aba48213..1010854e41 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -120,6 +120,7 @@ struct MenuValidationContext: Equatable { var canRefreshMaterializedView = false var canEditObjectComment = false var canCreateDatabase = false + var canCreateTable = false var canCopyObjects = false var canDuplicateDatabase = false var hasMaintenanceOperations = false @@ -283,7 +284,9 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(delete(_:)): return context.isConnected && (context.canDeleteSelectedRows || context.canDropSelectedTables) - case #selector(createNewTable(_:)), #selector(createNewView(_:)): + case #selector(createNewTable(_:)): + return context.isConnected && !context.isReadOnly && context.canCreateTable + case #selector(createNewView(_:)): return context.isConnected && !context.isReadOnly case #selector(createNewDatabase(_:)): return context.canCreateDatabase @@ -629,6 +632,7 @@ extension MainSplitViewController: NSMenuItemValidation { canRefreshMaterializedView: actions.canRefreshMaterializedView, canEditObjectComment: actions.canEditObjectComment, canCreateDatabase: actions.canCreateDatabase, + canCreateTable: actions.canCreateTable, canCopyObjects: actions.canCopyObjects, canDuplicateDatabase: actions.canDuplicateDatabase, hasMaintenanceOperations: !actions.maintenanceOperations.isEmpty, diff --git a/TablePro/Models/Schema/CreateTableDraftBuilder.swift b/TablePro/Models/Schema/CreateTableDraftBuilder.swift index c663305445..9149fb8acd 100644 --- a/TablePro/Models/Schema/CreateTableDraftBuilder.swift +++ b/TablePro/Models/Schema/CreateTableDraftBuilder.swift @@ -55,7 +55,8 @@ enum CreateTableDraftBuilder { indexes: [EditableIndexDefinition], foreignKeys: [EditableForeignKeyDefinition], dialect: ForeignKeyDialect, - includesEngineOptions: Bool + includesEngineOptions: Bool, + suppliesItsOwnKey: Bool = false ) -> CreateTablePlan { var issues: [SchemaDraftIssue] = [] @@ -72,14 +73,14 @@ enum CreateTableDraftBuilder { tab: .columns, row: nil, message: String(localized: "The table needs a name.") )) } - if resolvedColumns.isEmpty { + if resolvedColumns.isEmpty, !suppliesItsOwnKey { issues.append(SchemaDraftIssue( tab: .columns, row: nil, message: String(localized: "The table needs at least one column with a name and a type.") )) } - guard !trimmedName.isEmpty, !resolvedColumns.isEmpty else { + guard !trimmedName.isEmpty, !resolvedColumns.isEmpty || suppliesItsOwnKey else { return CreateTablePlan(definition: nil, indexes: [], issues: issues) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d5fcf75205..55d3752f85 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184294,6 +184294,42 @@ }, "The write was applied, but the servers did not confirm it as the write concern asks: %@" : { + }, + "MongoDB cannot address a field named %@. A field name cannot start with $ or contain a dot." : { + + }, + "%1$@ has type %2$@, which MongoDB does not have. Choose a type from the list." : { + + }, + "_id must be an objectId. MongoDB generates it for every new document." : { + + }, + "%@ cannot be the primary key. MongoDB keys every document by _id." : { + + }, + "Index %@ has a WHERE clause, an expression, included columns or a prefix length. MongoDB indexes take none of these." : { + + }, + "MongoDB has no %@ index." : { + + }, + "MongoDB already indexes _id." : { + + }, + "A hashed index cannot be unique." : { + + }, + "A hashed index takes one field." : { + + }, + "%@ cannot take rows from this file, so no table was created." : { + + }, + "The shell would reorder or drop a field named %@. Choose another name." : { + + }, + "A text index cannot be unique." : { + }, "Enter a document." : { diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index 785af9d252..3aa7eef7db 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -920,6 +920,12 @@ struct RowImportSheet: View { @MainActor private func prepareTable(_ definition: PluginCreateTableDefinition, scope: DatabaseScope) async throws { + guard ImportDataSinkAdapter.canWriteRows(into: connection.type) else { + throw PluginImportError.importFailed(String( + format: String(localized: "%@ cannot take rows from this file, so no table was created."), + connection.type.rawValue + )) + } let tableName = definition.tableName let statements = try await DatabaseManager.shared.createTableStatements( definition: definition, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index cfcee863db..4139116b15 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -89,7 +89,10 @@ extension MainContentCoordinator { // MARK: - Table Operations func createNewTable() { - guard !safeModeLevel.blocksAllWrites else { return } + guard !safeModeLevel.blocksAllWrites, + CreateTableEligibility.canCreateTable(with: DatabaseManager.shared.driver(for: connection.id)) else { + return + } if tabManager.tabs.isEmpty { tabManager.addCreateTableTab(databaseName: browseDatabaseName) diff --git a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift index 83b8f6978c..1c6dbfc852 100644 --- a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -105,6 +105,13 @@ extension MainContentCommandActions { isConnected && supportsContainerSwitching && !isReadOnly } + var canCreateTable: Bool { + guard isConnected, !isReadOnly, let coordinator else { return false } + return CreateTableEligibility.canCreateTable( + with: DatabaseManager.shared.driver(for: coordinator.connection.id) + ) + } + func createDatabase() { coordinator?.activeSheet = .createDatabase } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 2374e5bedd..80da297b55 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -105,6 +105,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { ), canBackUp: backupIsAvailable(), canCreateType: DatabaseManager.shared.driver(for: connectionId)?.createTypeTemplate(schema: nil) != nil, + canCreateTable: CreateTableEligibility.canCreateTable(with: DatabaseManager.shared.driver(for: connectionId)), objectToolSupport: .of(DatabaseManager.shared.driver(for: connectionId)) ) } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index aee95e269f..6eea1e81c1 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -60,6 +60,8 @@ internal struct DatabaseTreeMenuContext { internal var canBackUp: Bool = false /// Whether the driver can offer a CREATE TYPE template. Read-only mode still hides the item. internal var canCreateType: Bool = false + /// Whether the driver can create a table, from `CreateTableEligibility`. + internal var canCreateTable: Bool = false /// Which materialized-view and comment commands the driver has statements for. internal var objectToolSupport: DatabaseObjectToolEligibility.Support = .none } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index e66d26c008..befa48dfa2 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -601,7 +601,9 @@ internal enum DatabaseTreeMenuSpec { private static func backgroundSections(_ context: DatabaseTreeMenuContext) -> [DatabaseTreeMenuSection] { var creation: [DatabaseTreeMenuItem] = [] if !context.isReadOnly { - creation.append(.command(String(localized: "New Table…"), .createTable)) + if context.canCreateTable { + creation.append(.command(String(localized: "New Table…"), .createTable)) + } creation.append(.command(String(localized: "New View…"), .createView)) } creation += newSchemaItems(database: context.activeDatabase, context: context) diff --git a/TablePro/Views/Structure/CreateTableDraft.swift b/TablePro/Views/Structure/CreateTableDraft.swift index daaed49645..1d40f41b75 100644 --- a/TablePro/Views/Structure/CreateTableDraft.swift +++ b/TablePro/Views/Structure/CreateTableDraft.swift @@ -83,7 +83,8 @@ internal final class CreateTableDraft: ObservableObject { indexes: changeManager.workingIndexes, foreignKeys: changeManager.workingForeignKeys, dialect: ForeignKeyDialect.forType(databaseType), - includesEngineOptions: Self.offersEngineOptions(for: databaseType) + includesEngineOptions: Self.offersEngineOptions(for: databaseType), + suppliesItsOwnKey: PluginManager.shared.defaultPrimaryKeyColumn(for: databaseType) != nil ) } diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 03e973b502..12c7f43afa 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -402,7 +402,7 @@ struct CreateTableView: View { if composed.statements.isEmpty { sqlPreviewPlaceholder(composed.issues.first?.qualifiedMessage) } else { - DDLTextView(ddl: composed.preview, fontSize: .constant(13)) + DDLTextView(ddl: composed.preview, fontSize: .constant(13), databaseType: connection.type) } } else if let failure = draft.compositionFailure { sqlPreviewPlaceholder(failure) diff --git a/TableProTests/Core/Database/CreateTableEligibilityTests.swift b/TableProTests/Core/Database/CreateTableEligibilityTests.swift new file mode 100644 index 0000000000..5a3af5bb35 --- /dev/null +++ b/TableProTests/Core/Database/CreateTableEligibilityTests.swift @@ -0,0 +1,79 @@ +// +// CreateTableEligibilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private class EligibilityBaseDriver { + var supportsTransactions: Bool { false } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func switchDatabase(to database: String) async throws {} +} + +private final class NoCreateDriver: EligibilityBaseDriver, PluginDatabaseDriver, @unchecked Sendable {} + +private final class SQLCreateDriver: EligibilityBaseDriver, PluginDatabaseDriver, @unchecked Sendable { + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + "CREATE TABLE \(definition.tableName) (\(definition.columns.map { "\($0.name) \($0.dataType)" }.joined(separator: ", ")))" + } +} + +private final class FormCreateDriver: EligibilityBaseDriver, PluginDatabaseDriver, @unchecked Sendable { + func createTableFormSpec(schema: String?) -> PluginCreateTableFormSpec? { + PluginCreateTableFormSpec(sections: []) + } +} + +@MainActor +struct CreateTableEligibilityTests { + private func adapter(_ driver: any PluginDatabaseDriver) -> PluginDriverAdapter { + PluginDriverAdapter(connection: TestFixtures.makeConnection(type: .postgresql), pluginDriver: driver) + } + + @Test("A driver with no create hook cannot create a table") + func driverWithoutAHookCannotCreate() { + #expect(!CreateTableEligibility.canCreateTable(with: adapter(NoCreateDriver()))) + } + + @Test("A driver that writes CREATE TABLE can create a table") + func sqlDriverCanCreate() { + #expect(CreateTableEligibility.canCreateTable(with: adapter(SQLCreateDriver()))) + } + + @Test("A driver with its own Create Table form can create a table") + func formDriverCanCreate() { + #expect(CreateTableEligibility.canCreateTable(with: adapter(FormCreateDriver()))) + } + + @Test("No driver means no New Table") + func noDriverCannotCreate() { + #expect(!CreateTableEligibility.canCreateTable(with: nil)) + } +} diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 8909bd5127..637028487d 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -564,12 +564,24 @@ struct MainMenuValidationTests { context.isCurrentTabSchemaResolved = true context.hasTableSelection = true context.canTruncateSelectedTables = true + context.canCreateTable = true context.isReadOnly = true #expect(!enabled(#selector(MainSplitViewController.addRow(_:)), context)) #expect(!enabled(#selector(MainSplitViewController.truncateTable(_:)), context)) #expect(!enabled(#selector(MainSplitViewController.createNewTable(_:)), context)) } + @Test("New Table is disabled for an engine that cannot create a table") + func newTableFollowsTheDriver() { + var context = MenuValidationContext() + context.isConnected = true + #expect(!enabled(#selector(MainSplitViewController.createNewTable(_:)), context)) + #expect(enabled(#selector(MainSplitViewController.createNewView(_:)), context)) + + context.canCreateTable = true + #expect(enabled(#selector(MainSplitViewController.createNewTable(_:)), context)) + } + /// A view is a valid selection and a hopeless truncate. The menu bar used to ask only whether /// anything was selected, so it offered Truncate Table for one and the server refused the save. @Test("Truncate Table is disabled for a selection holding nothing truncatable") @@ -672,6 +684,7 @@ struct MainMenuValidationTests { context.canShowTableStructure = true context.canEditViewDefinition = true context.hasMaintenanceOperations = true + context.canCreateTable = true context.canInsertDocument = true return context } diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift index 7e089bda8a..a0e0bc565f 100644 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift @@ -8,6 +8,15 @@ import TableProPluginKit import Testing struct BsonDocumentFlattenerTests { + @Test("A field's kind is uniform only when every value that is there shares it") + func uniformKindNeedsEveryValueToAgree() { + let same: [[String: Any]] = [["_id": "a"], ["_id": "b"], ["other": 1]] + let mixed: [[String: Any]] = [["_id": "a"], ["_id": MongoDBObjectId(hex: "65a1b2c3d4e5f60718293a4b")]] + #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: same, representation: .unspecified) == .string) + #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: mixed, representation: .unspecified) == nil) + #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: [], representation: .unspecified) == nil) + } + // MARK: - unionColumns(from:) @Suite("unionColumns") diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift index b42e6cede7..7026a57126 100644 --- a/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift +++ b/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift @@ -5,6 +5,7 @@ import Foundation import JavaScriptCore +import TableProPluginKit import Testing /// Drives the real prelude in a real `JSContext` against a recording host. @@ -63,6 +64,66 @@ struct MongoScriptPreludeTests { ) } + @Test("A Create Table statement reaches the driver as one create command carrying its validator") + func createCollectionCarriesTheValidator() throws { + let host = RecordingHost() + host.replies = ["{\"ok\": 1}"] + let context = try makeContext(host) + let columns = [ + PluginColumnDefinition( + name: "title", dataType: "string", isNullable: false, defaultValue: nil, isPrimaryKey: false, + autoIncrement: false, comment: nil, unsigned: false, onUpdate: nil, charset: nil, collation: nil + ), + PluginColumnDefinition( + name: "tags", dataType: "array", isNullable: true, defaultValue: nil, isPrimaryKey: false, + autoIncrement: false, comment: nil, unsigned: false, onUpdate: nil, charset: nil, collation: nil + ) + ] + let statement = MongoDBCollectionDDL.createCollectionStatement( + for: PluginCreateTableDefinition(tableName: "articles", columns: columns) + ) + + context.evaluateScript(statement) + #expect(context.exception == nil) + + let request = try #require(host.requests(op: "command").first) + let commandText = try #require(request["command"] as? String) + let commandData = try #require(commandText.data(using: .utf8)) + let command = try #require(try JSONSerialization.jsonObject(with: commandData) as? [String: Any]) + #expect(command["create"] as? String == "articles") + let validator = try #require(command["validator"] as? [String: Any]) + let schema = try #require(validator["$jsonSchema"] as? [String: Any]) + #expect(schema["required"] as? [String] == ["title"]) + let properties = try #require(schema["properties"] as? [String: Any]) + #expect((properties["tags"] as? [String: Any])?["bsonType"] as? [String] == ["array", "null"]) + let propertiesAt = try #require(commandText.range(of: "\"properties\"")) + let tail = commandText[propertiesAt.upperBound...] + let titleAt = try #require(tail.range(of: "\"title\"")) + let tagsAt = try #require(tail.range(of: "\"tags\"")) + #expect(titleAt.lowerBound < tagsAt.lowerBound) + } + + @Test("A Create Table index reaches the driver with its keys in order and its options") + func createIndexKeepsKeysAndOptions() throws { + let host = RecordingHost() + let context = try makeContext(host) + let statement = try #require(MongoDBCollectionDDL.createIndexStatement( + collection: "articles", + index: PluginIndexDefinition(name: "lang_date", columns: ["lang", "date"], isUnique: true) + )) + + context.evaluateScript(statement) + #expect(context.exception == nil) + + let request = try #require(host.requests(op: "createIndex").first) + #expect(request["keys"] as? String == "{\"lang\":{\"$numberInt\":\"1\"},\"date\":{\"$numberInt\":\"1\"}}") + let optionsText = try #require(request["options"] as? String) + let optionsData = try #require(optionsText.data(using: .utf8)) + let options = try #require(try JSONSerialization.jsonObject(with: optionsData) as? [String: Any]) + #expect(options["name"] as? String == "lang_date") + #expect(options["unique"] as? Bool == true) + } + @Test("The prelude loads without a syntax error") func preludeLoads() throws { let host = RecordingHost() diff --git a/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift b/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift index f2847a200f..40f9b67d1b 100644 --- a/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift +++ b/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift @@ -65,6 +65,37 @@ struct CreateTableDraftBuilderTests { ) } + // MARK: - Engines that key every row themselves + + private func namedOnlyPlan(suppliesItsOwnKey: Bool) -> CreateTablePlan { + CreateTableDraftBuilder.plan( + tableName: "events", + options: CreateTableOptions(), + columns: [column("", "")], + indexes: [], + foreignKeys: [], + dialect: ForeignKeyDialect.forType(.sqlite), + includesEngineOptions: false, + suppliesItsOwnKey: suppliesItsOwnKey + ) + } + + @Test("A table with only a name is planned on an engine that supplies its own key") + func nameOnlyTableOnAnEngineWithItsOwnKey() throws { + let result = namedOnlyPlan(suppliesItsOwnKey: true) + #expect(result.issues.isEmpty) + let definition = try #require(result.definition) + #expect(definition.tableName == "events") + #expect(definition.columns.isEmpty) + } + + @Test("A table with only a name still needs a column everywhere else") + func nameOnlyTableNeedsAColumnOtherwise() { + let result = namedOnlyPlan(suppliesItsOwnKey: false) + #expect(result.definition == nil) + #expect(!result.issues.isEmpty) + } + // MARK: - The reported bug @Test("a foreign key with no constraint name is emitted") diff --git a/TableProTests/Plugins/MongoDBCollectionDDLTests.swift b/TableProTests/Plugins/MongoDBCollectionDDLTests.swift new file mode 100644 index 0000000000..39b91b0219 --- /dev/null +++ b/TableProTests/Plugins/MongoDBCollectionDDLTests.swift @@ -0,0 +1,300 @@ +// +// MongoDBCollectionDDLTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct MongoDBCollectionDDLTests { + private func column( + _ name: String, + _ type: String, + nullable: Bool = true, + primaryKey: Bool = false + ) -> PluginColumnDefinition { + PluginColumnDefinition( + name: name, + dataType: type, + isNullable: nullable, + defaultValue: nil, + isPrimaryKey: primaryKey, + autoIncrement: false, + comment: nil, + unsigned: false, + onUpdate: nil, + charset: nil, + collation: nil + ) + } + + private func definition(_ name: String, _ columns: [PluginColumnDefinition]) -> PluginCreateTableDefinition { + PluginCreateTableDefinition(tableName: name, columns: columns) + } + + private func index( + _ name: String, + _ columns: [String], + unique: Bool = false, + type: String? = nil, + whereClause: String? = nil + ) -> PluginIndexDefinition { + PluginIndexDefinition(name: name, columns: columns, isUnique: unique, indexType: type, whereClause: whereClause) + } + + /// The options the statement passes to `createCollection`, read back as JSON. + private func options(of statement: String) throws -> [String: Any] { + let start = try #require(statement.firstIndex(of: "{")) + let end = try #require(statement.lastIndex(of: "}")) + let data = try #require(String(statement[start ... end]).data(using: .utf8)) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func jsonSchema(of statement: String) throws -> [String: Any] { + let validator = try #require(try options(of: statement)["validator"] as? [String: Any]) + return try #require(validator["$jsonSchema"] as? [String: Any]) + } + + @Test("The reported draft becomes a collection whose validator lists every field in order") + func reportedDraftBecomesAValidator() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("articles", [ + column("_id", "objectId", nullable: false, primaryKey: true), + column("title", "string", nullable: false), + column("seoDescription", "string"), + column("slug", "string"), + column("content", "string"), + column("tags", "array"), + column("lang", "string"), + column("date", "date"), + column("schemaVersion", "int") + ])) + + #expect(statement.hasPrefix("db.createCollection(\"articles\", {")) + let schema = try jsonSchema(of: statement) + #expect(schema["bsonType"] as? String == "object") + #expect(schema["required"] as? [String] == ["title"]) + let properties = try #require(schema["properties"] as? [String: Any]) + #expect((properties["_id"] as? [String: Any])?["bsonType"] as? String == "objectId") + #expect((properties["title"] as? [String: Any])?["bsonType"] as? String == "string") + #expect((properties["tags"] as? [String: Any])?["bsonType"] as? [String] == ["array", "null"]) + #expect((properties["schemaVersion"] as? [String: Any])?["bsonType"] as? [String] == ["int", "null"]) + + let declared = MongoDBCollectionSchema.parse(jsonSchema: statement.jsonSchemaText) + #expect(declared.fields.map { $0.name } == [ + "_id", "title", "seoDescription", "slug", "content", "tags", "lang", "date", "schemaVersion" + ]) + } + + @Test("Leaving the server's validation level and action out keeps its strict, error defaults") + func validationDefaultsAreLeftToTheServer() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement( + for: definition("articles", [column("title", "string")]) + ) + let options = try options(of: statement) + #expect(Set(options.keys) == ["validator"]) + } + + @Test("A collection with no fields beyond _id stays schemaless") + func onlyIdMeansNoValidator() { + #expect(MongoDBCollectionDDL.createCollectionStatement(for: definition("events", [])) == "db.createCollection(\"events\")") + #expect( + MongoDBCollectionDDL.createCollectionStatement( + for: definition("events", [column("_id", "objectId", nullable: false, primaryKey: true)]) + ) == "db.createCollection(\"events\")" + ) + } + + @Test("No required list is written when every field is nullable, because an empty one is refused") + func noRequiredWhenEverythingIsNullable() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement( + for: definition("notes", [column("body", "string")]) + ) + #expect(try jsonSchema(of: statement)["required"] == nil) + } + + @Test("A type is matched to its alias whatever its case, since the server's aliases are case-sensitive") + func typeAliasesAreNormalised() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("t", [ + column("ref", "ObjectId", nullable: false), + column("name", "STRING", nullable: false), + column("flag", "Bool", nullable: false) + ])) + let properties = try #require(try jsonSchema(of: statement)["properties"] as? [String: Any]) + #expect((properties["ref"] as? [String: Any])?["bsonType"] as? String == "objectId") + #expect((properties["name"] as? [String: Any])?["bsonType"] as? String == "string") + #expect((properties["flag"] as? [String: Any])?["bsonType"] as? String == "bool") + } + + @Test("A nullable null field is not written as a duplicate type") + func nullTypeIsNotDuplicated() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement( + for: definition("t", [column("nothing", "null")]) + ) + let properties = try #require(try jsonSchema(of: statement)["properties"] as? [String: Any]) + #expect((properties["nothing"] as? [String: Any])?["bsonType"] as? String == "null") + } + + @Test("Names with quotes, backslashes and non-ASCII text stay inside their string literals") + func namesAreEscaped() throws { + let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("a\"b\\c", [ + column("título \"x\"", "string", nullable: false) + ])) + #expect(statement.hasPrefix("db.createCollection(\"a\\\"b\\\\c\", {")) + let schema = try jsonSchema(of: statement) + #expect(schema["required"] as? [String] == ["título \"x\""]) + } + + @Test("A type MongoDB lacks still produces a statement, so the server explains the refusal") + func unknownTypeStillProducesAStatement() { + let statement = MongoDBCollectionDDL.createCollectionStatement( + for: definition("t", [column("n", "VARCHAR(255)", nullable: false)]) + ) + #expect(statement.contains("\"bsonType\": \"VARCHAR(255)\"")) + } + + // MARK: - Refusals + + @Test("A primary key on a field other than _id is refused and names the field") + func primaryKeyOnIdFieldIsRefused() throws { + let reason = try #require(MongoDBCollectionDDL.refusal(for: .addColumn( + column("id", "objectId", nullable: false, primaryKey: true) + ))) + #expect(reason.contains("id")) + #expect(reason.contains("_id")) + } + + @Test("_id as the primary key is accepted") + func idAsPrimaryKeyIsAccepted() { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn( + column("_id", "objectId", nullable: false, primaryKey: true) + )) == nil) + } + + @Test("_id typed as anything but objectId is refused, because the grid lets the server generate it") + func idWithAnotherTypeIsRefused() { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column("_id", "string", nullable: false))) != nil) + } + + @Test("A type that is not a BSON alias is refused and names the field and the type") + func unknownTypeIsRefused() throws { + let reason = try #require(MongoDBCollectionDDL.refusal(for: .addColumn(column("title", "VARCHAR")))) + #expect(reason.contains("title")) + #expect(reason.contains("VARCHAR")) + } + + @Test("Every listed type is accepted", arguments: MongoDBBsonType.aliases) + func listedTypesAreAccepted(alias: String) { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column("f", alias))) == nil) + } + + @Test("Field names a JavaScript object reorders or drops are refused", arguments: ["10", "0", "__proto__"]) + func shellReorderedNamesAreRefused(name: String) { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) != nil) + } + + @Test("Names that only look numeric are kept", arguments: ["007", "10a", "a10"]) + func nonCanonicalNumericNamesAreKept(name: String) { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) == nil) + } + + @Test("Field names MongoDB cannot address are refused", arguments: ["$price", "a.b"]) + func unaddressableNamesAreRefused(name: String) { + #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) != nil) + } + + // MARK: - Indexes + + @Test("An index keeps its fields in the order they were given") + func indexKeepsKeyOrder() { + let statement = MongoDBCollectionDDL.createIndexStatement( + collection: "articles", index: index("lang_date", ["lang", "date", "slug"], unique: true) + ) + #expect(statement == "db.articles.createIndex({\"lang\": 1, \"date\": 1, \"slug\": 1}, {\"name\": \"lang_date\", \"unique\": true})") + } + + @Test("An index with no name lets the server name it") + func unnamedIndexOmitsItsName() { + #expect(MongoDBCollectionDDL.createIndexStatement(collection: "articles", index: index("", ["slug"])) + == "db.articles.createIndex({\"slug\": 1})") + } + + @Test("A collection the shell cannot name as a property is reached through getCollection") + func indexOnAnAwkwardCollectionName() throws { + let statement = try #require(MongoDBCollectionDDL.createIndexStatement( + collection: "my.collection", index: index("", ["slug"]) + )) + #expect(statement.hasPrefix("db.getCollection(\"my.collection\").createIndex(")) + } + + @Test("The editor's index types map to MongoDB's key kinds") + func indexTypesMap() { + let cases: [(type: String, key: String)] = [ + ("BTREE", "1"), ("HASH", "\"hashed\""), ("FULLTEXT", "\"text\""), ("SPATIAL", "\"2dsphere\"") + ] + for entry in cases { + let statement = MongoDBCollectionDDL.createIndexStatement( + collection: "c", index: index("", ["f"], type: entry.type) + ) + #expect(statement == "db.c.createIndex({\"f\": \(entry.key)})", "\(entry.type)") + } + } + + @Test("An index type MongoDB has no equivalent for is refused and produces no statement") + func unsupportedIndexTypeIsRefused() { + let gin = index("g", ["f"], type: "GIN") + #expect(MongoDBCollectionDDL.createIndexStatement(collection: "c", index: gin) == nil) + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(gin)) != nil) + #expect(MongoDBCollectionDDL.unsupportedIndexTypes.contains("GIN")) + } + + @Test("A hashed index can be neither unique nor compound") + func hashedIndexLimits() { + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f"], unique: true, type: "HASH"))) != nil) + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f", "g"], type: "HASH"))) != nil) + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f"], type: "HASH"))) == nil) + } + + @Test("An ascending index on _id alone is refused, because MongoDB already has one") + func indexOnIdIsRefused() { + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("i", ["_id"]))) != nil) + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("i", ["_id", "lang"]))) == nil) + } + + @Test("A hashed index on _id is its own index, the one a hashed shard key needs") + func hashedIdIndexIsAccepted() { + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["_id"], type: "HASH"))) == nil) + } + + @Test("A text index cannot be unique") + func uniqueTextIndexIsRefused() { + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("t", ["body"], unique: true, type: "FULLTEXT"))) != nil) + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("t", ["body"], type: "FULLTEXT"))) == nil) + } + + @Test("A partial index written in SQL is refused") + func whereClauseIsRefused() { + #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("p", ["f"], whereClause: "f > 0"))) != nil) + } +} + +private extension String { + /// The `$jsonSchema` object inside a `createCollection` statement, as text. + var jsonSchemaText: String { + guard let marker = range(of: "\"$jsonSchema\": ") else { return "{}" } + let tail = self[marker.upperBound...] + var depth = 0 + var end = tail.startIndex + for index in tail.indices { + if tail[index] == "{" { depth += 1 } + if tail[index] == "}" { + depth -= 1 + if depth == 0 { + end = index + break + } + } + } + return String(tail[tail.startIndex ... end]) + } +} diff --git a/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift b/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift new file mode 100644 index 0000000000..14d10629d9 --- /dev/null +++ b/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift @@ -0,0 +1,139 @@ +// +// MongoDBCollectionSchemaTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct MongoDBCollectionSchemaTests { + /// A `listCollections` reply as `bson_as_canonical_extended_json` writes it, fields deliberately + /// out of alphabetical order so a dictionary round trip would show. + private func reply(options: String) -> String { + """ + { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "blog.$cmd.listCollections", "firstBatch" : [ \ + { "name" : "articles", "type" : "collection", "options" : \(options), \ + "info" : { "readOnly" : false }, "idIndex" : { "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "name" : "_id_" } } ] }, \ + "ok" : { "$numberDouble" : "1.0" } } + """ + } + + private let articlesOptions = """ + { "validator" : { "$jsonSchema" : { "bsonType" : "object", "required" : [ "title", "status" ], \ + "properties" : { "title" : { "bsonType" : "string" }, "tags" : { "bsonType" : [ "array", "null" ] }, \ + "date" : { "bsonType" : [ "date", "null" ] }, "status" : { "enum" : [ "draft", "live" ] }, \ + "views" : { "bsonType" : "long" }, "attachment" : { "bsonType" : "binData" }, \ + "score" : { "type" : "number" } } } }, "validationLevel" : "strict", "validationAction" : "error" } + """ + + @Test("Declared fields come back in the order the validator lists them") + func fieldsKeepTheirOrder() { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(schema.fields.map { $0.name } == ["title", "tags", "date", "status", "views", "attachment", "score"]) + } + + @Test("A field is required only when the validator's required list names it") + func requiredComesFromTheRequiredList() throws { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(try #require(schema.field(named: "title")).isRequired) + #expect(try #require(schema.field(named: "status")).isRequired) + #expect(try #require(schema.field(named: "tags")).isRequired == false) + } + + @Test("A nullable union and a string enum type the value, and an open or binary type does not") + func valueKinds() { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(schema.valueKinds["title"] == .string) + #expect(schema.valueKinds["tags"] == .array) + #expect(schema.valueKinds["date"] == .date) + #expect(schema.valueKinds["views"] == .int64) + #expect(schema.valueKinds["status"] == .string) + #expect(schema.valueKinds["attachment"] == nil) + #expect(schema.valueKinds["score"] == nil) + } + + @Test("A string enum is kept as the field's allowed values") + func enumsAreKept() { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(schema.allowedValues["status"] == ["draft", "live"]) + #expect(schema.allowedValues["title"] == nil) + } + + @Test("A declared field is shown with the type name a sampled one of its kind would have") + func declaredTypeNamesMatchSampledOnes() throws { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + let date = try #require(schema.field(named: "date")) + let attachment = try #require(schema.field(named: "attachment")) + let score = try #require(schema.field(named: "score")) + #expect(date.columnTypeName(representation: .unspecified) + == BsonDocumentFlattener.typeName(for: .date, representation: .unspecified)) + #expect(attachment.columnTypeName(representation: .unspecified) + == BsonDocumentFlattener.typeName(for: .binary(subtype: 0), representation: .unspecified)) + #expect(score.columnTypeName(representation: .unspecified) == "number") + } + + @Test("A collection with no validator, or a validator without $jsonSchema, declares nothing") + func noSchemaDeclaresNothing() { + #expect(MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: "{ }")).isEmpty) + #expect(MongoDBCollectionSchema.parse( + listCollectionsReply: reply(options: "{ \"validator\" : { \"age\" : { \"$gte\" : { \"$numberInt\" : \"0\" } } } }") + ).isEmpty) + #expect(MongoDBCollectionSchema.parse(listCollectionsReply: "{ \"cursor\" : { \"firstBatch\" : [ ] } }").isEmpty) + #expect(MongoDBCollectionSchema.parse(listCollectionsReply: "not json").isEmpty) + } + + @Test("A field whose name carries an escaped quote keeps its own name and its place") + func escapedFieldNamesSurvive() { + let schema = MongoDBCollectionSchema.parse(jsonSchema: """ + { "properties" : { "a\\"b" : { "bsonType" : "string" }, "ab" : { "bsonType" : "int" }, \ + "plain" : { "bsonType" : "bool" } } } + """) + #expect(schema.fields.map { $0.name } == ["a\"b", "ab", "plain"]) + } + + @Test("An enum of strings with no type of its own types the field as a string") + func stringEnumTypesTheField() throws { + let schema = MongoDBCollectionSchema.parse(jsonSchema: """ + { "properties" : { "level" : { "enum" : [ "1", "2" ] } } } + """) + let level = try #require(schema.field(named: "level")) + #expect(level.valueKind == .string) + #expect(level.allowedValues == ["1", "2"]) + } + + /// U+0600 joins the `\` after it into one `Character`, so a scan by `Character` missed the + /// escape and ended the string at the quote libbson had escaped. + @Test("A key holding a Prepend character before an escaped quote is read whole") + func membersReadKeysScalarByScalar() { + let json = "{\"x\u{0600}\\\"y\": 1, \"next\": 2}" + let keys = MongoScriptJson.members(of: json).map { $0.key } + #expect(keys == ["x\u{0600}\"y", "next"]) + } + + @Test("The command asks for one collection by name, escaped") + func listCollectionsCommandIsEscaped() throws { + let command = MongoDBCollectionSchema.listCollectionsCommand(for: "a\"b") + let data = try #require(command.data(using: .utf8)) + let parsed = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect((parsed["filter"] as? [String: Any])?["name"] as? String == "a\"b") + #expect(parsed["maxTimeMS"] as? Int == MongoDBCollectionSchema.listCollectionsTimeoutMS) + } + + // MARK: - Shape + + @Test("An empty collection presents _id and then its declared fields") + func emptyCollectionColumns() { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(MongoDBCollectionShape.emptyCollectionColumns(declaring: schema) + == ["_id", "title", "tags", "date", "status", "views", "attachment", "score"]) + #expect(MongoDBCollectionShape.emptyCollectionColumns(declaring: .empty) == ["_id"]) + } + + @Test("Declared fields no sampled document holds follow the sampled columns") + func unseenDeclaredColumns() { + let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) + #expect(MongoDBCollectionShape.declaredColumnsMissing(from: ["_id", "title", "extra"], schema: schema) + == ["tags", "date", "status", "views", "attachment", "score"]) + } +} diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index a5ef6a86db..1f026bbf0b 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -863,6 +863,28 @@ struct MongoDBQueryBuilderTests { } } + /// U+0D4E is a Unicode Prepend letter: it joins the next scalar into one `Character`, so a + /// `(` after it answered `isLetter` and the name went into the statement bare, as code. + @Test("A collection name is spelled bare only when it is a plain ASCII identifier") + func accessorSpellsOnlyAsciiIdentifiersBare() { + #expect(MongoCollectionAccessor.expression(for: "orders") == "db.orders") + #expect(MongoCollectionAccessor.expression(for: "order_2") == "db.order_2") + for name in ["a\u{0D4E}(\u{0D4E})", "tên", "2025", "a b", "a;b"] { + #expect(MongoCollectionAccessor.expression(for: name).hasPrefix("db.getCollection(\""), "\(name)") + } + } + + @Test("Escaping works scalar by scalar, so a quote joined to a Prepend letter is still escaped") + func escapingSeesQuotesInsideGraphemeClusters() throws { + let hostile = "x\u{0600}\"}); db.victim.drop(); ({\"" + let escaped = MongoDBQueryBuilder.escapeJsonString(hostile) + let decoded = try JSONSerialization.jsonObject( + with: Data("\"\(escaped)\"".utf8), options: [.fragmentsAllowed] + ) as? String + #expect(decoded == hostile) + #expect(MongoDBQueryBuilder.escapeJsonString("a\r\nb") == "a\\r\\nb") + } + // MARK: - Raw filter normalization private static let normalizer = MongoDBRawFilterNormalizer() diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift index 456030bb4e..b1149aa0b4 100644 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift @@ -254,7 +254,9 @@ struct MongoDBStatementGeneratorTests { #expect(document?["leadingZero"] as? String == "01") } - @Test("Insert quotes integers that overflow Int64 but keeps in-range integers numeric") + /// The maximum Int64 is past 2^53, where a bare JavaScript literal rounds: JavaScriptCore reads + /// `9223372036854775807` as `9223372036854776000`. It has to cross as `$numberLong`. + @Test("Insert quotes integers that overflow Int64 and writes the largest ones as $numberLong") func insertQuotesInt64Overflow() { let gen = MongoDBStatementGenerator( collectionName: "users", @@ -281,7 +283,7 @@ struct MongoDBStatementGeneratorTests { let document = firstArgumentObject(in: results[0].statement) #expect(document?["overflow"] as? String == "12345678901234567890") - #expect(document?["maxInt64"] as? Int64 == 9_223_372_036_854_775_807) + #expect((document?["maxInt64"] as? [String: Any])?["$numberLong"] as? String == "9223372036854775807") } @Test("Insert not in insertedRowIndices is skipped") diff --git a/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift b/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift index 2f0e2d2025..9d9781817d 100644 --- a/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift +++ b/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift @@ -11,12 +11,14 @@ struct MongoDBWriteBackTypeTests { private func update( column: String, to newValue: PluginCellValue, - kinds: [String: BsonValueKind] + kinds: [String: BsonValueKind], + declared: [String: BsonValueKind] = [:] ) -> String? { let gen = MongoDBStatementGenerator( collectionName: "products", columns: ["_id", column], - columnKinds: kinds + columnKinds: kinds, + declaredKinds: declared ) let change = PluginRowChange( rowIndex: 0, @@ -81,4 +83,215 @@ struct MongoDBWriteBackTypeTests { let stmt = try #require(update(column: "note", to: .text("{draft"), kinds: [:])) #expect(stmt.contains(#""note": "{draft""#)) } + + // MARK: - Dates and ObjectIds + + @Test("A date column is written as a BSON date, not the text the grid shows") + func dateColumnWritesADate() throws { + let stmt = try #require(update(column: "when", to: .text("2024-01-02T03:04:05Z"), kinds: ["when": .date])) + #expect(stmt.contains(#""when": {"$date": {"$numberLong": "1704164645000"}}"#)) + } + + @Test("A date the picker wrote is read in the local zone, which is the zone the picker shows") + func pickedDateIsLocalWallClock() throws { + let stmt = try #require(update(column: "when", to: .text("2024-01-02 03:04:05"), kinds: ["when": .date])) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + let local = try #require(formatter.date(from: "2024-01-02 03:04:05")) + let millis = Int64((local.timeIntervalSince1970 * 1_000).rounded()) + #expect(stmt.contains("{\"$date\": {\"$numberLong\": \"\(millis)\"}}")) + } + + @Test("A date-only value is a date too") + func dateOnlyIsADate() throws { + let stmt = try #require(update(column: "day", to: .text("2024-01-02"), kinds: ["day": .date])) + #expect(stmt.contains(#""day": {"$date": "#)) + } + + @Test("A bare number typed into a date column stays a number rather than becoming 1970") + func numberInADateColumnStaysANumber() throws { + let stmt = try #require(update(column: "day", to: .text("2024"), kinds: ["day": .date])) + #expect(stmt.contains(#""day": 2024"#)) + } + + @Test("A field holding ObjectIds keeps holding ObjectIds") + func objectIdColumnWritesAnObjectId() throws { + let stmt = try #require(update( + column: "authorId", to: .text("65a1b2c3d4e5f60718293a4b"), kinds: ["authorId": .objectId] + )) + #expect(stmt.contains(#""authorId": {"$oid": "65a1b2c3d4e5f60718293a4b"}"#)) + } + + // MARK: - Declared kinds + + @Test("A field the validator declares a string is written as one, whatever the text looks like") + func declaredStringIsAlwaysAString() throws { + for text in ["123", "true", "null", "[1, 2]"] { + let stmt = try #require(update(column: "code", to: .text(text), kinds: [:], declared: ["code": .string])) + let escaped = text.replacingOccurrences(of: "\"", with: "\\\"") + #expect(stmt.contains("\"code\": \"\(escaped)\""), "\(text)") + } + } + + @Test("A sampled string column still reads typed JSON, because sampling is a majority vote") + func sampledStringKeepsItsSpelling() throws { + let stmt = try #require(update(column: "meta", to: .text(#"{"a":1}"#), kinds: ["meta": .string])) + #expect(stmt.contains(#""meta": {"a":1}"#)) + } + + @Test("A declared type outranks what the sampled documents held") + func declaredKindWins() throws { + let stmt = try #require(update( + column: "rate", to: .text("3"), kinds: ["rate": .int32], declared: ["rate": .double] + )) + #expect(stmt.contains(#"{"$numberDouble": "3"}"#)) + } + + @Test("An int field takes a bare number that fits in 32 bits") + func int32ColumnWritesABareNumber() throws { + let stmt = try #require(update(column: "n", to: .text("42"), kinds: [:], declared: ["n": .int32])) + #expect(stmt.contains(#""n": 42"#)) + } + + // MARK: - Numbers past a double + + /// Measured with JavaScriptCore: `JSON.stringify({a: 9007199254740993})` gives + /// `9007199254740992`, so the bare literal reached the server one off. + @Test("An integer past 2^53 is written as a 64-bit integer so JavaScript cannot round it") + func hugeIntegerIsNotRounded() throws { + let stmt = try #require(update(column: "big", to: .text("9007199254740993"), kinds: [:])) + #expect(stmt.contains(#""big": {"$numberLong": "9007199254740993"}"#)) + } + + @Test("An integer JavaScript holds exactly stays bare") + func exactIntegerStaysBare() throws { + let stmt = try #require(update(column: "n", to: .text("9007199254740992"), kinds: [:])) + #expect(stmt.contains(#""n": 9007199254740992"#)) + } + + // MARK: - Statements are code + + /// The statement is JavaScript the shell evaluates, so text that only looks like an array + /// used to be pasted in as code. + @Test("Stored text shaped like an array but not JSON is written as a string, never run") + func arrayShapedCodeIsAString() throws { + let hostile = #"[db.getCollection("audit").drop()]"# + let stmt = try #require(update(column: "note", to: .text(hostile), kinds: [:])) + #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))")) + #expect(!stmt.contains(#""note": [db"#)) + } + + @Test("Stored text shaped like a document but not JSON is written as a string, never run") + func documentShapedCodeIsAString() throws { + let hostile = #"{a: db.dropDatabase()}"# + let stmt = try #require(update(column: "note", to: .text(hostile), kinds: [:])) + #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))")) + } + + /// U+0600 is a Unicode Prepend character, so it and the quote after it are one `Character`, which + /// matched no escape case and went into the statement raw, closing the string literal early. + @Test("A quote joined to a Prepend character is still escaped, so the value cannot close its string") + func quoteInsideAGraphemeClusterIsEscaped() throws { + let hostile = "x\u{0600}\"}}); db.getCollection(\"victim\").drop(); db.x.find({\"a\": {\"b\": \"" + let stmt = try #require(update(column: "note", to: .text(hostile), kinds: [:], declared: ["note": .string])) + let start = try #require(stmt.range(of: "\"$set\": ")).upperBound + let setDocument = String(stmt[start...].dropLast(2)) + let data = try #require(setDocument.data(using: .utf8)) + let parsed = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(parsed["note"] as? String == hostile) + } + + /// Measured on macOS 27: `JSONSerialization` refuses each of these, so none reaches the statement + /// as source. This pins it, because a parser that stopped at the first complete value would let + /// the rest run. + @Test("JSON followed by more text is written as a string, never pasted as source") + func jsonPrefixWithTrailingCodeIsAString() throws { + let payloads = [ + "{}, injected: db.dropDatabase(), tail: {}", + #"{"a":1}, db.dropDatabase(), {}"#, + "[1], db.dropDatabase(), [2]" + ] + for hostile in payloads { + let stmt = try #require(update(column: "note", to: .text(hostile), kinds: [:])) + #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))"), "\(hostile)") + let gen = MongoDBStatementGenerator(collectionName: "notes", columns: ["_id", "note"]) + let restored = try #require(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .text(hostile)]])?.first) + #expect(restored.statement.contains("\"note\": \(MongoScriptJson.jsonString(hostile))"), "\(hostile)") + } + } + + @Test("Restoring a deleted row writes a hostile stored string back as a string") + func restoreDoesNotRunStoredText() throws { + let gen = MongoDBStatementGenerator(collectionName: "notes", columns: ["_id", "body"]) + let hostile = #"[db.getCollection("audit").drop()]"# + let statement = try #require(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .text(hostile)]])?.first) + #expect(statement.statement.contains("\"body\": \(MongoScriptJson.jsonString(hostile))")) + } + + // MARK: - _id + + private func updateFilter( + id: String, + idKind: BsonValueKind?, + sampledKinds: [String: BsonValueKind] = [:] + ) -> String? { + let gen = MongoDBStatementGenerator( + collectionName: "codes", + columns: ["_id", "label"], + columnKinds: sampledKinds, + identityKind: idKind + ) + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(columnIndex: 1, columnName: "label", oldValue: .text("a"), newValue: .text("b"))], + originalRow: [.text(id), .text("a")] + ) + return gen.generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] + ).first?.statement + } + + @Test("A string _id that looks like a number is matched as a string") + func stringIdIsMatchedAsAString() throws { + let stmt = try #require(updateFilter(id: "1001", idKind: .string)) + #expect(stmt.contains(#"{"_id": "1001"}"#)) + } + + @Test("A string _id that looks like an ObjectId is matched as a string") + func hexStringIdIsMatchedAsAString() throws { + let stmt = try #require(updateFilter(id: "65a1b2c3d4e5f60718293a4b", idKind: .string)) + #expect(stmt.contains(#"{"_id": "65a1b2c3d4e5f60718293a4b"}"#)) + } + + @Test("A 64-bit _id is matched as one") + func longIdIsMatchedAsALong() throws { + let stmt = try #require(updateFilter(id: "42", idKind: .int64)) + #expect(stmt.contains(#"{"_id": {"$numberLong": "42"}}"#)) + } + + @Test("A mostly-string _id column does not quote a row whose _id kinds differ") + func mixedIdKindsFallBackToTheSpelling() throws { + let stmt = try #require(updateFilter( + id: "65a1b2c3d4e5f60718293a4b", idKind: nil, sampledKinds: ["_id": .string] + )) + #expect(stmt.contains(#"{"_id": {"$oid": "65a1b2c3d4e5f60718293a4b"}}"#)) + } + + /// JavaScriptCore evaluates a bare `010` in a statement as the octal number 8. + @Test("An int written with a leading zero is sent as its decimal value") + func int32IsCanonicalised() throws { + let stmt = try #require(update(column: "n", to: .text("010"), kinds: [:], declared: ["n": .int32])) + #expect(stmt.contains(#""n": 10"#)) + let negative = try #require(update(column: "n", to: .text("-010"), kinds: ["n": .int32])) + #expect(negative.contains(#""n": -10"#)) + } + + @Test("An _id of unknown kind is still read from its spelling") + func unknownIdFallsBackToItsSpelling() throws { + let stmt = try #require(updateFilter(id: "65a1b2c3d4e5f60718293a4b", idKind: nil)) + #expect(stmt.contains(#"{"_id": {"$oid": "65a1b2c3d4e5f60718293a4b"}}"#)) + } } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index d6ab12ad1a..c3d0c8305a 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -50,6 +50,7 @@ struct DatabaseTreeMenuSpecTests { canCopyObjects: Bool = true, canDuplicateDatabase: Bool = true, canCreateType: Bool = false, + canCreateTable: Bool = true, supportsCreateSchema: Bool = false, supportsSchemaOwner: Bool = false, supportsSchemaPrivileges: Bool = false, @@ -116,6 +117,7 @@ struct DatabaseTreeMenuSpecTests { canCopyObjects: canCopyObjects, canDuplicateDatabase: canDuplicateDatabase, canCreateType: canCreateType, + canCreateTable: canCreateTable, objectToolSupport: objectToolSupport ) } @@ -184,6 +186,16 @@ struct DatabaseTreeMenuSpecTests { #expect(issued.contains(.createView)) } + /// MongoDB before its plugin could create a collection, and Redis, Kafka and every other engine + /// without a create hook, opened the grid and refused only once it was filled in. + @Test("An engine that cannot create a table is not offered New Table") + func emptyAreaHidesNewTableWithoutCreateSupport() { + let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: nil, canCreateTable: false))) + + #expect(!issued.contains(.createTable)) + #expect(issued.contains(.createView)) + } + @Test("Read-only hides creation from the empty area too") func readOnlyEmptyAreaHidesCreation() { let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: nil, isReadOnly: true))) diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index ad9ee36263..8145037bd8 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -73,11 +73,13 @@ Over an SSH tunnel only the first host is used and the rest of the list is dropp ## Browsing collections -Click a collection to page through its documents. Column layout is inferred by sampling documents, not read from a validator, so a field missing from the sample gets no column. ObjectIds render as strings, arrays and nested objects as formatted JSON. +Click a collection to page through its documents. The columns are the fields in the documents on screen, then any field the collection's `$jsonSchema` validator declares that none of them hold, so an empty collection still shows the fields it was created with. ObjectIds render as strings, arrays and nested objects as formatted JSON. + +An edited cell keeps its field's type. A date stays a date, whether typed as `2024-01-02T03:04:05Z`, as `2024-01-02`, or picked in the date picker, which writes your local time. An ObjectId stays an ObjectId, an integer past 2^53 is sent as a 64-bit integer, and a field the validator declares as `string` is written as text even when it reads `123` or `true`. Where the grid has no type to go on, `123` is a number and `{"a": 1}` a document; text that only looks like JSON is written as a string. The filter bar's column picker lists paths inside nested objects and arrays of objects, so `customer.country` and `items.sku` filter directly; a row on an array field chooses **any element** or **same element**, which makes one array entry satisfy every row set to it. See [Filtering](/features/filtering#nested-fields). A field name containing a literal dot is left out of the picker, since MongoDB reads a dot as a path separator; reach it with `$getField` inside `$expr`. -The Structure tab lists a collection's indexes; create and drop them from a query tab with `db.users.createIndex({"email": 1})` and `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": …})`. +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": …})`. ### Binary UUIDs @@ -85,9 +87,29 @@ A binary subtype 4 field renders as `UUID("8cd003eb-4a25-4324-9332-88fce2da0d1a" Once set, the value renders as `LegacyJavaUUID("…")` and reads that way everywhere, filters and MQL export included. Nothing stored is rewritten, `uuidRepresentation=javaLegacy` in a pasted URL sets the same option, and a change takes effect on the next connect. +## Creating a collection + +Choose **Database > New Table…**. Each row of the grid is a field, and its type is one of the BSON types in the list: `objectId`, `string`, `int`, `long`, `double`, `decimal`, `date`, `bool`, `array`, `object` and the rest. **Create Table** turns the rows into a `$jsonSchema` validator and runs `db.createCollection("articles", {"validator": …})`, which **SQL Preview** shows first. + + + SQL Preview tab of Create Table showing db.createCollection for articles with a $jsonSchema listing _id, title, tags, date and schemaVersion + SQL Preview tab of Create Table showing db.createCollection for articles with a $jsonSchema listing _id, title, tags, date and schemaVersion + + +| Row | Becomes | +|-----|---------| +| **Nullable** NO | The field is in `required`: a document without it, or with `null`, is refused | +| **Nullable** YES | The field is optional and may hold `null` | +| `_id` | Always the key, typed `objectId`. Leave it out and MongoDB still adds one | +| No rows, only a name | A collection with no validator | + +MongoDB enforces the validator with its defaults, `strict` and `error`, so an insert or edit of the wrong type fails with `Document failed validation`. **Primary Key** on any field other than `_id` is refused, and so are field names that start with `$` or contain a dot. + +Each row on the **Indexes** tab runs as a `createIndex` after the collection is made, with its fields in the order listed. **BTREE** is an ascending key, **HASH** is `hashed`, **FULLTEXT** is `text`, and **SPATIAL** is `2dsphere`. A hashed index takes one field and cannot be unique, and a text index cannot be unique either. + ## Inserting documents -A field exists only in the documents that hold it, so a collection with no documents shows `_id` alone and has no column to type a field into. Choose **Edit > Insert Document…** to write a whole document instead. It is on a row's context menu too, and on the context menu of an empty grid. +A field exists only in the documents that hold it, so a collection with no documents and no validator shows `_id` alone and has no column to type a field into. Choose **Edit > Insert Document…** to write a whole document instead. It is on a row's context menu too, and on the context menu of an empty grid. Insert Document sheet over the empty events collection, holding a document with a name, a channel, a date, a count and a list of tags @@ -190,6 +212,8 @@ New connections default to **Disabled**, and the driver has no TLS fallback: **P - A row with no `_id` cannot be updated or deleted. The save is skipped rather than matched on the remaining fields. Keep `_id` in the projection so every row carries one. - `_id` is read-only in the grid, and a row added with **Add Row** is inserted without one so the server generates it. To choose your own, use **Insert Document…**. - Transactions are not exposed. Statements always run standalone, on any topology. +- A collection takes one text index. A second **FULLTEXT** row fails after the collection and the indexes before it are created: list every text field in one index instead. +- **New Table…** writes the validator with the server's own level and action. To log bad documents instead of refusing them, run `db.runCommand({collMod: "articles", validationAction: "warn"})` after creating the collection. - Nested paths filter but do not sort. Sorting works on the grid's own columns. - **same element** covers a field one array deep. A path through an array inside another array needs nested `$elemMatch`, so those filter with dot notation only. - GridFS buckets are not browsable, and change streams are unsupported. diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index a2dadaf373..c246853d2a 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -7,7 +7,7 @@ Dropping a table cannot be undone, and **Cascade** in the confirmation dialog wi ## Create a table or view -**Database > New Table…** opens the visual structure editor (see [Table Structure](/features/table-structure)); on [DynamoDB](/databases/dynamodb#creating-and-changing-tables) it opens a form for the keys, capacity and indexes instead. **Database > New View…** opens a query tab holding a `CREATE VIEW` template for the engine. Both are also on the sidebar's right-click menu over empty space, where [safe mode](/features/safe-mode) removes them rather than dimming them. +**Database > New Table…** opens the visual structure editor (see [Table Structure](/features/table-structure)); on [DynamoDB](/databases/dynamodb#creating-and-changing-tables) it opens a form for the keys, capacity and indexes instead. **Database > New View…** opens a query tab holding a `CREATE VIEW` template for the engine. Both are also on the sidebar's right-click menu over empty space, where [safe mode](/features/safe-mode) removes them rather than dimming them. On an engine with no way to create a table, such as Redis or Kafka, **New Table…** is dimmed in the menu bar and absent from the sidebar. ## Drop and truncate diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 563d328d4a..d89ae349ca 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -238,7 +238,7 @@ Lists partitions and parts from `system.parts`. **Optimize** merges parts; **Dro ## Creating a new table -Choose **Database > New Table…**, or right-click empty space in the sidebar and choose **New Table…**. The tab holds a **Table Name** field (with Engine, Charset, and Collation pickers on MySQL and MariaDB), the same three editing grids, and a **SQL Preview** tab carrying the live `CREATE TABLE`. **Create Table** (`Cmd+Enter` or `Cmd+S`) executes it and opens the new table. +Choose **Database > New Table…**, or right-click empty space in the sidebar and choose **New Table…**. The tab holds a **Table Name** field (with Engine, Charset, and Collation pickers on MySQL and MariaDB), the same three editing grids, and a **SQL Preview** tab carrying the live statements it will run. **Create Table** (`Cmd+Enter` or `Cmd+S`) executes it and opens the new table. On the **Foreign Keys** grid, **Columns**, **Ref Table**, and **Ref Columns** open menus of the new table's own columns, the database's tables, and the referenced table's columns. Each menu ends in **Custom…** for a name the list does not carry, and picking a second entry in a comma-separated cell appends to it. @@ -249,12 +249,12 @@ A row that is started and unfinished is never dropped from the generated SQL. ** Indexes run as their own `CREATE INDEX` statements after the table, in one transaction where the engine has transactional DDL. -Visual table creation is supported for MySQL, MariaDB, PostgreSQL, PGlite, SQLite, SQL Server, ClickHouse, DuckDB, Oracle, Snowflake, libSQL, Cloudflare D1, Trino, Teradata, and Dameng. +Visual table creation is supported for MySQL, MariaDB, PostgreSQL, PGlite, SQLite, SQL Server, ClickHouse, DuckDB, Oracle, Snowflake, libSQL, Cloudflare D1, Trino, Teradata, Dameng, and [MongoDB](/databases/mongodb#creating-a-collection). DynamoDB uses a form instead. On every other engine **New Table…** is dimmed. ## MongoDB collections -MongoDB structure is read-only, and inferred from the collection's first 200 documents: top-level field names are unioned across the sample, and each field takes its most common BSON type. `_id` comes first, marked as the primary key. The DDL tab shows indexes as `createIndex()` commands for `mongosh`, plus the validator and capped-collection options where present. +MongoDB structure is read-only, and inferred from the collection's first 200 documents: top-level field names are unioned across the sample, and each field takes its most common BSON type. Fields the collection's `$jsonSchema` validator declares follow, including ones no sampled document holds, and a field in the validator's `required` list is not nullable. `_id` comes first, marked as the primary key. The DDL tab shows indexes as `createIndex()` commands for `mongosh`, plus the validator and capped-collection options where present. ## Limitations diff --git a/docs/images/mongodb-create-collection-dark.png b/docs/images/mongodb-create-collection-dark.png new file mode 100644 index 0000000000..5b95102a85 Binary files /dev/null and b/docs/images/mongodb-create-collection-dark.png differ diff --git a/docs/images/mongodb-create-collection.png b/docs/images/mongodb-create-collection.png new file mode 100644 index 0000000000..c8ed790d98 Binary files /dev/null and b/docs/images/mongodb-create-collection.png differ diff --git a/project.yml b/project.yml index 75ae854669..ecd82d75d7 100644 --- a/project.yml +++ b/project.yml @@ -507,6 +507,8 @@ targets: - Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift - Plugins/MongoDBDriverPlugin/MongoDBDecimal128.swift - Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift + - Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift + - Plugins/MongoDBDriverPlugin/MongoDBCollectionSchema.swift - Plugins/MongoDBDriverPlugin/MongoDBCreateDatabasePlan.swift - Plugins/MongoDBDriverPlugin/MongoDBFilterClause.swift - Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift