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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down
173 changes: 173 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import Foundation
import TableProPluginKit

enum MongoDBCollectionDDL {
static let idField = "_id"
static let unsupportedIndexTypes: Set<String> = ["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
}
}
Loading
Loading