diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf7875d7..b36d76f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Autocomplete for MongoDB queries. Typing `db.` lists collections, `db.users.` lists the driver methods, and inside a query you get field names plus the operators that are valid in that spot: query operators in a filter, update operators in an update, stage names in a pipeline, and expression operators inside a stage. (#2095) - PostgreSQL autocomplete now knows the operators, including `::`, the JSON ones (`->`, `->>`, `#>`, `@>`, `?`, `?|`, `?&`), array and range containment, regex matching and full-text search. Each one shows what it does and which types it works on. Typing `::` offers the type names. (#2095) - PostgreSQL autocomplete covers about 400 built-in functions and the multi-word syntax people actually type, such as `ON CONFLICT DO UPDATE SET`, `GENERATED ALWAYS AS IDENTITY` and window frame clauses. (#2095) +- MongoDB field suggestions include nested paths. A document with `address: { city }` now suggests `address.city`, not just `address`. (#2095) +- MongoDB updates accept an options argument, so `db.users.updateOne({...}, {...}, {upsert: true})` upserts instead of ignoring the option. `arrayFilters` and `hint` are passed through too. (#2095) +- Format Query follows the editor language. On a MongoDB tab it lays out filters and pipelines by nesting depth instead of running the SQL formatter over them. (#2095) ### Added diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index 6215235de..eb4b40ff0 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -259,6 +259,59 @@ struct BsonDocumentFlattener { return value > 0 ? "Infinity" : "-Infinity" } + // MARK: - Nested Field Paths + + /// Dotted paths across the sampled documents, including paths inside nested objects and + /// inside the objects an array holds. This is deliberately separate from `unionColumns`, + /// which stays flat because the grid renders a nested object as one JSON column. + static func fieldPaths( + from documents: [[String: Any]], + representation: MongoDBUuidRepresentation, + maxDepth: Int = 4 + ) -> [PluginFieldPath] { + var kinds: [String: [BsonValueKind: Int]] = [:] + var depths: [String: Int] = [:] + var order: [String] = [] + + func visit(_ document: [String: Any], prefix: String, depth: Int) { + guard depth <= maxDepth else { return } + + for key in document.keys.sorted() { + guard let value = document[key], !(value is NSNull) else { continue } + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + + if depths[path] == nil { + depths[path] = depth + order.append(path) + } + kinds[path, default: [:]][valueKind(for: value, representation: representation), default: 0] += 1 + + if let nested = value as? [String: Any] { + visit(nested, prefix: path, depth: depth + 1) + } else if let array = value as? [Any] { + for element in array.prefix(20) { + guard let nested = element as? [String: Any] else { continue } + visit(nested, prefix: path, depth: depth + 1) + } + } + } + } + + for document in documents { + visit(document, prefix: "", depth: 1) + } + + return order.compactMap { path in + guard let winner = kinds[path]?.max(by: { $0.value < $1.value })?.key, + let depth = depths[path] else { return nil } + return PluginFieldPath( + path: path, + typeName: typeName(for: winner, representation: representation), + depth: depth + ) + } + } + // MARK: - Type Inference private static func inferValueKind( diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index bb0378055..0e3f3dcb1 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -215,6 +215,61 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .map { PluginTableInfo(name: $0, type: "table", rowCount: nil) } } + private func executeWrite( + kind: MongoWriteKind, + collection: String, + filter: String, + document: String, + options: MongoWriteOptions, + conn: MongoDBConnection, + db: String, + startTime: Date + ) async throws -> PluginQueryResult { + var fields: [String] = [] + if options.upsert { fields.append("\"upsert\": true") } + if let arrayFilters = options.arrayFilters { fields.append("\"arrayFilters\": \(arrayFilters)") } + if let hint = options.hint { fields.append("\"hint\": \(hint)") } + let extras = fields.isEmpty ? "" : ", " + fields.joined(separator: ", ") + + if kind == .findOneAndUpdate { + let command = """ + {"findAndModify": "\(escapeJsonString(collection))", "query": \(filter), \ + "update": \(document), "new": true\(extras)} + """ + let docs = try await conn.runCommand(command, database: db) + return buildPluginResult(from: docs.isEmpty ? [] : [docs[0]], startTime: startTime) + } + + let command = """ + {"update": "\(escapeJsonString(collection))", \ + "updates": [{"q": \(filter), "u": \(document), "multi": \(kind == .updateMany)\(extras)}]} + """ + let result = try await conn.runCommand(command, database: db) + let modified = (result.first?["nModified"] as? Int64) + ?? (result.first?["nModified"] as? Int).map(Int64.init) ?? 0 + let upserted = (result.first?["upserted"] as? [Any])?.count ?? 0 + let affected = Int(modified) + upserted + + return PluginQueryResult( + columns: ["modifiedCount", "upsertedCount"], columnTypeNames: ["Int64", "Int64"], + rows: [[.text(String(modified)), .text(String(upserted))]], rowsAffected: affected, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + func sampleFieldPaths(table: String, schema: String?, limit: Int) async throws -> [PluginFieldPath] { + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + + let docs = try await conn.find( + database: currentDb, collection: table, + filter: "{}", sort: nil, projection: nil, skip: 0, limit: max(1, limit) + ).docs + + return BsonDocumentFlattener.fieldPaths(from: docs, representation: uuidRepresentation) + } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected @@ -637,6 +692,14 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(update)" return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + case .write(let kind, let collection, let filter, let document, _): + let multi = kind == .updateMany + if kind == .findOneAndUpdate { + let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(document)" + return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" + } + return "db.runCommand({\"explain\": {\"update\": \"\(escapeJsonString(collection))\", \"updates\": [{\"q\": \(filter), \"u\": \(document), \"multi\": \(multi)}]}, \"verbosity\": \"executionStats\"})" + case .findOneAndReplace(let collection, let filter, let replacement): let cmd = "\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(replacement)" return "db.runCommand({\"explain\": {\(cmd)}, \"verbosity\": \"executionStats\"})" @@ -923,6 +986,12 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let docs = try await conn.runCommand(cmd, database: db) return buildPluginResult(from: docs.isEmpty ? [] : [docs[0]], startTime: startTime) + case .write(let kind, let collection, let filter, let document, let options): + return try await executeWrite( + kind: kind, collection: collection, filter: filter, document: document, + options: options, conn: conn, db: db, startTime: startTime + ) + case .findOneAndReplace(let collection, let filter, let replacement): let cmd = "{\"findAndModify\": \"\(escapeJsonString(collection))\", \"query\": \(filter), \"update\": \(replacement), \"new\": true}" let docs = try await conn.runCommand(cmd, database: db) diff --git a/Plugins/TableProPluginKit/MongoShellParser.swift b/Plugins/TableProPluginKit/MongoShellParser.swift index 4b316d18d..e239bf0d9 100644 --- a/Plugins/TableProPluginKit/MongoShellParser.swift +++ b/Plugins/TableProPluginKit/MongoShellParser.swift @@ -32,6 +32,37 @@ public enum MongoOperation { case listCollections case listDatabases case ping + case write( + kind: MongoWriteKind, + collection: String, + filter: String, + document: String, + options: MongoWriteOptions + ) +} + +/// Which write a `.write` operation performs. Only produced when the shell call carries an +/// options argument; the plain two-argument forms keep their original operation cases. +public enum MongoWriteKind: String, Sendable { + case updateOne + case updateMany + case replaceOne + case findOneAndUpdate +} + +/// The third argument of an update, replace or findOneAndUpdate call. +public struct MongoWriteOptions: Sendable { + public var upsert: Bool + public var arrayFilters: String? + public var hint: String? + public var raw: String + + public init(upsert: Bool = false, arrayFilters: String? = nil, hint: String? = nil, raw: String = "{}") { + self.upsert = upsert + self.arrayFilters = arrayFilters + self.hint = hint + self.raw = raw + } } /// Options for a find operation parsed from chained methods @@ -297,16 +328,16 @@ public struct MongoShellParser { operation = .insertMany(collection: collection, documents: arg) case "updateOne": - let (filter, update) = try parseTwoArgs(arg, method: "updateOne") - operation = .updateOne(collection: collection, filter: filter, update: update) + operation = makeWrite(.updateOne, collection: collection, + args: try parseWriteArgs(arg, method: "updateOne")) case "updateMany": - let (filter, update) = try parseTwoArgs(arg, method: "updateMany") - operation = .updateMany(collection: collection, filter: filter, update: update) + operation = makeWrite(.updateMany, collection: collection, + args: try parseWriteArgs(arg, method: "updateMany")) case "replaceOne": - let (filter, replacement) = try parseTwoArgs(arg, method: "replaceOne") - operation = .replaceOne(collection: collection, filter: filter, replacement: replacement) + operation = makeWrite(.replaceOne, collection: collection, + args: try parseWriteArgs(arg, method: "replaceOne")) case "deleteOne": let filter = arg.isEmpty ? "{}" : arg @@ -324,8 +355,8 @@ public struct MongoShellParser { operation = .dropIndex(collection: collection, indexName: arg) case "findOneAndUpdate": - let (filter, update) = try parseTwoArgs(arg, method: "findOneAndUpdate") - operation = .findOneAndUpdate(collection: collection, filter: filter, update: update) + operation = makeWrite(.findOneAndUpdate, collection: collection, + args: try parseWriteArgs(arg, method: "findOneAndUpdate")) case "findOneAndReplace": let (filter, replacement) = try parseTwoArgs(arg, method: "findOneAndReplace") @@ -587,6 +618,78 @@ public struct MongoShellParser { return (parts[0], parts[1]) } + /// Parse a write call's arguments, keeping the optional third options document. + private static func parseWriteArgs( + _ args: String, + method: String + ) throws -> (filter: String, document: String, options: MongoWriteOptions?) { + let parts = try splitTopLevelArgs(args) + guard parts.count >= 2 else { + throw MongoShellParseError.missingArgument("\(method) requires 2 arguments") + } + guard parts.count > 2 else { + return (parts[0], parts[1], nil) + } + return (parts[0], parts[1], try parseWriteOptions(parts[2], method: method)) + } + + private static func parseWriteOptions(_ raw: String, method: String) throws -> MongoWriteOptions { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{"), trimmed.hasSuffix("}") else { + throw MongoShellParseError.invalidSyntax("\(method) options must be a document") + } + + var options = MongoWriteOptions(raw: trimmed) + let inner = String(trimmed.dropFirst().dropLast()) + + for entry in try splitTopLevelArgs(inner) { + let pair = entry.split(separator: ":", maxSplits: 1).map { + $0.trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\"'`")) + } + guard pair.count == 2 else { continue } + + switch pair[0] { + case "upsert": + options.upsert = pair[1] == "true" + case "arrayFilters": + options.arrayFilters = pair[1] + case "hint": + options.hint = pair[1] + default: + continue + } + } + + return options + } + + private static func makeWrite( + _ kind: MongoWriteKind, + collection: String, + args: (filter: String, document: String, options: MongoWriteOptions?) + ) -> MongoOperation { + guard let options = args.options else { + switch kind { + case .updateOne: + return .updateOne(collection: collection, filter: args.filter, update: args.document) + case .updateMany: + return .updateMany(collection: collection, filter: args.filter, update: args.document) + case .replaceOne: + return .replaceOne(collection: collection, filter: args.filter, replacement: args.document) + case .findOneAndUpdate: + return .findOneAndUpdate(collection: collection, filter: args.filter, update: args.document) + } + } + + return .write( + kind: kind, + collection: collection, + filter: args.filter, + document: args.document, + options: options + ) + } + /// Parse two arguments where the second is optional private static func parseTwoArgsOptional(_ args: String) throws -> (String, String?) { let parts = try splitTopLevelArgs(args) diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index e59a7e130..f23ab4560 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -115,6 +115,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] + func sampleFieldPaths(table: String, schema: String?, limit: Int) async throws -> [PluginFieldPath] func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] @@ -286,6 +287,12 @@ public extension PluginDatabaseDriver { return result } + /// Default: no nested field paths. Document stores override this to sample documents and + /// report the dotted paths their nested structure exposes, which a flat column list cannot. + func sampleFieldPaths(table: String, schema: String?, limit: Int) async throws -> [PluginFieldPath] { + [] + } + /// Default: fetches foreign keys per-table sequentially (N+1 round-trips). /// SQL drivers should override with a single bulk query (e.g. INFORMATION_SCHEMA.KEY_COLUMN_USAGE). func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { diff --git a/Plugins/TableProPluginKit/PluginFieldPath.swift b/Plugins/TableProPluginKit/PluginFieldPath.swift new file mode 100644 index 000000000..7223a4574 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginFieldPath.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A field a document store exposes at a nested path, reported for query authoring. +/// A flat column list cannot express these: a document store types a nested object as one +/// opaque column, so `address.city` never appears among its columns. +public struct PluginFieldPath: Codable, Sendable, Hashable { + public let path: String + public let typeName: String + public let depth: Int + + public init(path: String, typeName: String, depth: Int) { + self.path = path + self.typeName = typeName + self.depth = depth + } +} diff --git a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift index f5f4b474e..cd5071329 100644 --- a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift +++ b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift @@ -139,7 +139,22 @@ final class MongoCompletionService: QueryCompletionService { private func fieldItems(for collection: String?) async -> [SQLCompletionItem] { guard let schemaProvider, let collection else { return [] } - return await schemaProvider.columnCompletionItems(for: collection) + + let paths = await schemaProvider.fieldPaths(for: collection) + guard !paths.isEmpty else { + return await schemaProvider.columnCompletionItems(for: collection) + } + + return paths.map { path in + var item = SQLCompletionItem( + label: path.path, + kind: .column, + insertText: path.path, + detail: path.typeName + ) + item.sortPriority = SQLCompletionKind.column.basePriority + path.depth + return item + } } private func fieldPathItems(for collection: String?) async -> [SQLCompletionItem] { diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index f3730e2f6..f639175ad 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -29,18 +29,24 @@ actor SQLSchemaProvider { let fetchColumns: @Sendable (_ table: String, _ schema: String?) async throws -> [ColumnInfo] let fetchAllColumns: @Sendable () async throws -> [String: [ColumnInfo]] let fetchSchemaTables: (@Sendable (_ schema: String) async throws -> [TableInfo])? + let sampleFieldPaths: (@Sendable (_ table: String, _ limit: Int) async throws -> [PluginFieldPath])? init( fetchColumns: @escaping @Sendable (_ table: String, _ schema: String?) async throws -> [ColumnInfo], fetchAllColumns: @escaping @Sendable () async throws -> [String: [ColumnInfo]], - fetchSchemaTables: (@Sendable (_ schema: String) async throws -> [TableInfo])? = nil + fetchSchemaTables: (@Sendable (_ schema: String) async throws -> [TableInfo])? = nil, + sampleFieldPaths: (@Sendable (_ table: String, _ limit: Int) async throws -> [PluginFieldPath])? = nil ) { self.fetchColumns = fetchColumns self.fetchAllColumns = fetchAllColumns self.fetchSchemaTables = fetchSchemaTables + self.sampleFieldPaths = sampleFieldPaths } } + private var fieldPathCache: [String: [PluginFieldPath]] = [:] + private var fieldPathTasks: [String: Task<[PluginFieldPath], Never>] = [:] + private var knownSchemas: [String] = [] private var knownDatabases: [String] = [] @@ -175,6 +181,8 @@ actor SQLSchemaProvider { self.tables = newTables self.columnCache.removeAll() self.columnAccessOrder.removeAll() + self.fieldPathCache.removeAll() + self.fieldPathTasks.removeAll() self.cachedDriver = driver self.isLoading = false self.lastLoadError = nil @@ -186,6 +194,8 @@ actor SQLSchemaProvider { eagerColumnTask = nil columnCache.removeAll() columnAccessOrder.removeAll() + fieldPathCache.removeAll() + fieldPathTasks.removeAll() if cachedDriver != nil { startEagerColumnLoad() } @@ -383,6 +393,22 @@ actor SQLSchemaProvider { } } + /// Dotted field paths for a document-store collection, cached per collection. + /// Concurrent callers await the same sample instead of firing duplicate queries. + func fieldPaths(for tableName: String, sampleSize: Int = 50) async -> [PluginFieldPath] { + let key = tableName.lowercased() + if let cached = fieldPathCache[key] { return cached } + if let inFlight = fieldPathTasks[key] { return await inFlight.value } + guard let sample = metadataSource?.sampleFieldPaths else { return [] } + + let task = Task { (try? await sample(tableName, sampleSize)) ?? [] } + fieldPathTasks[key] = task + let paths = await task.value + fieldPathTasks[key] = nil + if !paths.isEmpty { fieldPathCache[key] = paths } + return paths + } + /// Get completion items for all columns of tables in scope func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] { // swiftlint:disable:next large_tuple diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 0f924ce40..2e844b2d6 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -86,6 +86,10 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Default implementation falls back to per-table fetchColumns. func fetchAllColumns() async throws -> [String: [ColumnInfo]] + /// Dotted field paths a document store exposes for a collection, for query authoring. + /// Default implementation returns nothing, which is correct for every SQL driver. + func sampleFieldPaths(table: String, limit: Int) async throws -> [PluginFieldPath] + /// Fetch indexes for a specific table func fetchIndexes(table: String) async throws -> [IndexInfo] @@ -387,6 +391,10 @@ extension DatabaseDriver { return result } + func sampleFieldPaths(table: String, limit: Int) async throws -> [PluginFieldPath] { + [] + } + /// Default fetchAllColumns: falls back to per-table fetchColumns (N+1). /// Drivers should override with a single bulk query where possible. func fetchAllColumns() async throws -> [String: [ColumnInfo]] { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 903af4c37..9e9793878 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -478,6 +478,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { // MARK: - Batch Operations + func sampleFieldPaths(table: String, limit: Int) async throws -> [PluginFieldPath] { + try await pluginDriver.sampleFieldPaths(table: table, schema: pluginDriver.currentSchema, limit: limit) + } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { let pluginResult = try await pluginDriver.fetchAllColumns(schema: pluginDriver.currentSchema) var result: [String: [ColumnInfo]] = [:] diff --git a/TablePro/Core/Services/Formatting/MongoShellFormatter.swift b/TablePro/Core/Services/Formatting/MongoShellFormatter.swift new file mode 100644 index 000000000..6c6c4ed7c --- /dev/null +++ b/TablePro/Core/Services/Formatting/MongoShellFormatter.swift @@ -0,0 +1,179 @@ +import Foundation + +struct MongoShellFormatter: QueryFormatting { + private static let indent = " " + private static let inlineWidthLimit = 60 + private static let maximumInputLength = 10_000_000 + + private enum Token { + case text(String) + case string(String) + case open(Character) + case close(Character) + case comma + case colon + } + + func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { + let source = text as NSString + guard source.length <= Self.maximumInputLength else { + return QueryFormatResult(text: text, cursorOffset: cursorOffset) + } + + let tokens = tokenize(source) + let expansion = expansionFlags(for: tokens) + return QueryFormatResult(text: emit(tokens, expansion: expansion), cursorOffset: nil) + } + + private func tokenize(_ source: NSString) -> [Token] { + var tokens: [Token] = [] + var pending = "" + var index = 0 + + func flushPending() { + let trimmed = pending.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { tokens.append(.text(trimmed)) } + pending = "" + } + + while index < source.length { + let scalar = Character(UnicodeScalar(source.character(at: index)) ?? " ") + + if scalar == "\"" || scalar == "'" || scalar == "`" { + flushPending() + let literal = readStringLiteral(source, from: index, delimiter: scalar) + tokens.append(.string(literal.value)) + index = literal.end + continue + } + + switch scalar { + case "{", "[": + flushPending() + tokens.append(.open(scalar)) + case "}", "]": + flushPending() + tokens.append(.close(scalar)) + case ",": + flushPending() + tokens.append(.comma) + case ":": + flushPending() + tokens.append(.colon) + default: + pending.append(scalar) + } + + index += 1 + } + + flushPending() + return tokens + } + + private func readStringLiteral( + _ source: NSString, + from start: Int, + delimiter: Character + ) -> (value: String, end: Int) { + var value = String(delimiter) + var index = start + 1 + let backslash = UInt16(UnicodeScalar("\\").value) + let terminator = UInt16(String(delimiter).unicodeScalars.first?.value ?? 0) + + while index < source.length { + let character = source.character(at: index) + value.append(Character(UnicodeScalar(character) ?? " ")) + if character == backslash, index + 1 < source.length { + value.append(Character(UnicodeScalar(source.character(at: index + 1)) ?? " ")) + index += 2 + continue + } + index += 1 + if character == terminator { break } + } + + return (value, index) + } + + private func expansionFlags(for tokens: [Token]) -> [Int: Bool] { + var flags: [Int: Bool] = [:] + var stack: [(index: Int, width: Int, hasNestedContainer: Bool)] = [] + + for (index, token) in tokens.enumerated() { + switch token { + case .open: + if !stack.isEmpty { stack[stack.count - 1].hasNestedContainer = true } + stack.append((index, 0, false)) + case .close: + guard let frame = stack.popLast() else { break } + let expands = frame.hasNestedContainer || frame.width > Self.inlineWidthLimit + flags[frame.index] = expands + if !stack.isEmpty { stack[stack.count - 1].width += frame.width + 2 } + case .text(let value), .string(let value): + if !stack.isEmpty { stack[stack.count - 1].width += value.count } + case .comma, .colon: + if !stack.isEmpty { stack[stack.count - 1].width += 2 } + } + } + + return flags + } + + private func emit(_ tokens: [Token], expansion: [Int: Bool]) -> String { + var output = "" + var depth = 0 + var expandedStack: [Bool] = [] + var atLineStart = true + + func newline() { + output += "\n" + String(repeating: Self.indent, count: depth) + atLineStart = true + } + + for (index, token) in tokens.enumerated() { + switch token { + case .open(let character): + let expands = expansion[index] ?? false + output.append(character) + expandedStack.append(expands) + if expands { + depth += 1 + newline() + } + case .close(let character): + let expands = expandedStack.popLast() ?? false + if expands { + depth = max(0, depth - 1) + newline() + } + output.append(character) + atLineStart = false + case .comma: + output.append(",") + if expandedStack.last == true { + newline() + } else { + output.append(" ") + atLineStart = false + } + case .colon: + output.append(": ") + atLineStart = false + case .text(let value), .string(let value): + if !atLineStart, needsSpace(before: value, in: output) { output.append(" ") } + output.append(value) + atLineStart = false + } + } + + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func needsSpace(before value: String, in output: String) -> Bool { + guard let last = output.last, let first = value.first else { return false } + if last == "(" || first == ")" || first == "." || last == "." { return false } + if last == " " || last == "\n" { return false } + return true + } +} diff --git a/TablePro/Core/Services/Formatting/QueryFormatter.swift b/TablePro/Core/Services/Formatting/QueryFormatter.swift new file mode 100644 index 000000000..f72874e00 --- /dev/null +++ b/TablePro/Core/Services/Formatting/QueryFormatter.swift @@ -0,0 +1,39 @@ +import Foundation +import TableProPluginKit + +struct QueryFormatResult { + let text: String + let cursorOffset: Int? +} + +protocol QueryFormatting { + func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult +} + +struct SQLQueryFormatter: QueryFormatting { + private let dialect: DatabaseType + private let formatter = SQLFormatterService() + + init(dialect: DatabaseType) { + self.dialect = dialect + } + + func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { + let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: .default) + return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset) + } +} + +@MainActor +enum QueryFormatterFactory { + static func make(for databaseType: DatabaseType?) -> QueryFormatting { + let dialect = databaseType ?? .mysql + + switch PluginManager.shared.editorLanguage(for: dialect) { + case .javascript: + return MongoShellFormatter() + default: + return SQLQueryFormatter(dialect: dialect) + } + } +} diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index e0b4c94a2..bfa4f64ef 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -75,6 +75,11 @@ final class SchemaProviderRegistry { try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchTables(schema: schema) } + }, + sampleFieldPaths: { table, limit in + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await driver.sampleFieldPaths(table: table, limit: limit) + } } ) let provider = SQLSchemaProvider(metadataSource: source) diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index c0b1b973d..8c277fde2 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -297,23 +297,17 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { func performFormatSQL() { guard let textView = controller?.textView else { return } - let dialect = databaseType ?? .mysql - let formatter = SQLFormatterService() + let formatter = QueryFormatterFactory.make(for: databaseType) let scope = FormatScopeResolver.resolve( fullText: textView.string, selectedRange: textView.selectedRange() ) do { - let result = try formatter.format( - scope.sql, - dialect: dialect, - cursorOffset: scope.cursorOffset, - options: .default - ) + let result = try formatter.format(scope.sql, cursorOffset: scope.cursorOffset) let replacement = scope.isSelection - ? FormatScopeResolver.reapplyBoundaryWhitespace(from: scope.sql, to: result.formattedSQL) - : result.formattedSQL + ? FormatScopeResolver.reapplyBoundaryWhitespace(from: scope.sql, to: result.text) + : result.text textView.replaceCharacters(in: scope.range, with: replacement) let replacementLength = (replacement as NSString).length let caretLocation: Int diff --git a/TableProTests/Core/MongoDB/BsonFieldPathTests.swift b/TableProTests/Core/MongoDB/BsonFieldPathTests.swift new file mode 100644 index 000000000..73ee6a636 --- /dev/null +++ b/TableProTests/Core/MongoDB/BsonFieldPathTests.swift @@ -0,0 +1,90 @@ +// +// BsonFieldPathTests.swift +// TableProTests +// +// Nested dotted field paths, which the flat column list cannot express. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("BSON Field Paths") +struct BsonFieldPathTests { + private func paths(_ documents: [[String: Any]], maxDepth: Int = 4) -> [PluginFieldPath] { + BsonDocumentFlattener.fieldPaths(from: documents, representation: .unspecified, maxDepth: maxDepth) + } + + @Test("a nested object contributes its own dotted paths") + func testNestedObjectPaths() { + let result = paths([["address": ["city": "Hanoi", "zip": 100_000]]]) + let names = result.map(\.path) + #expect(names.contains("address")) + #expect(names.contains("address.city")) + #expect(names.contains("address.zip")) + } + + @Test("a flat column list still reports the nested object as one column") + func testUnionColumnsStaysFlat() { + let columns = BsonDocumentFlattener.unionColumns(from: [["address": ["city": "Hanoi"]]]) + #expect(columns == ["address"]) + } + + @Test("depth is recorded so shallower fields can rank first") + func testDepthRecorded() { + let result = paths([["a": ["b": ["c": 1]]]]) + #expect(result.first { $0.path == "a" }?.depth == 1) + #expect(result.first { $0.path == "a.b" }?.depth == 2) + #expect(result.first { $0.path == "a.b.c" }?.depth == 3) + } + + @Test("maxDepth stops the walk") + func testMaxDepthStopsWalk() { + let result = paths([["a": ["b": ["c": ["d": 1]]]]], maxDepth: 2) + let names = result.map(\.path) + #expect(names.contains("a.b")) + #expect(!names.contains("a.b.c")) + } + + @Test("objects inside an array contribute paths") + func testArrayOfObjects() { + let result = paths([["items": [["sku": "A1"], ["sku": "B2", "qty": 3]]]]) + let names = result.map(\.path) + #expect(names.contains("items.sku")) + #expect(names.contains("items.qty")) + } + + @Test("paths merge across documents with different shapes") + func testPathsMergeAcrossDocuments() { + let result = paths([["a": ["x": 1]], ["a": ["y": 2]], ["b": 3]]) + let names = result.map(\.path) + #expect(names.contains("a.x")) + #expect(names.contains("a.y")) + #expect(names.contains("b")) + } + + @Test("a null value contributes no path") + func testNullValueSkipped() { + let result = paths([["a": NSNull(), "b": 1]]) + let names = result.map(\.path) + #expect(!names.contains("a")) + #expect(names.contains("b")) + } + + @Test("the majority type wins for a path") + func testMajorityTypeWins() { + let result = paths([["n": 1], ["n": 2], ["n": "text"]]) + #expect(["INTEGER", "BIGINT"].contains(result.first { $0.path == "n" }?.typeName ?? "")) + } + + @Test("a nested object is still typed as JSON at its own path") + func testNestedObjectTypedAsJson() { + let result = paths([["address": ["city": "Hanoi"]]]) + #expect(result.first { $0.path == "address" }?.typeName == "JSON") + } + + @Test("no documents means no paths") + func testEmptyInput() { + #expect(paths([]).isEmpty) + } +} diff --git a/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift b/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift index ad971ba51..295edb954 100644 --- a/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift +++ b/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift @@ -76,4 +76,75 @@ struct MongoShellParserChainedMethodTests { try MongoShellParser.parse("db.users.find({}).limit(abc)") } } + + // MARK: - Write Options + + @Test("updateOne without options keeps the original operation case") + func testUpdateOneWithoutOptionsUnchanged() throws { + let op = try MongoShellParser.parse("db.users.updateOne({a: 1}, {$set: {b: 2}})") + guard case .updateOne(let collection, let filter, let update) = op else { + Issue.record("Expected .updateOne operation") + return + } + #expect(collection == "users") + #expect(filter == "{a: 1}") + #expect(update == "{$set: {b: 2}}") + } + + @Test("upsert in the third argument is carried, not dropped") + func testUpsertCarried() throws { + let op = try MongoShellParser.parse("db.users.updateOne({a: 1}, {$set: {b: 2}}, {upsert: true})") + guard case .write(let kind, let collection, _, _, let options) = op else { + Issue.record("Expected .write operation") + return + } + #expect(kind == .updateOne) + #expect(collection == "users") + #expect(options.upsert) + } + + @Test("upsert false is carried as false") + func testUpsertFalse() throws { + let op = try MongoShellParser.parse("db.users.updateMany({}, {$set: {b: 2}}, {upsert: false})") + guard case .write(let kind, _, _, _, let options) = op else { + Issue.record("Expected .write operation") + return + } + #expect(kind == .updateMany) + #expect(!options.upsert) + } + + @Test("arrayFilters in the third argument is carried") + func testArrayFiltersCarried() throws { + let query = "db.users.updateOne({}, {$set: {\"g.$[e].v\": 1}}, {arrayFilters: [{\"e.v\": {$gt: 5}}]})" + guard case .write(_, _, _, _, let options) = try MongoShellParser.parse(query) else { + Issue.record("Expected .write operation") + return + } + #expect(options.arrayFilters == "[{\"e.v\": {$gt: 5}}]") + } + + @Test("replaceOne and findOneAndUpdate report their own kind") + func testWriteKinds() throws { + guard case .write(let replaceKind, _, _, _, _) = + try MongoShellParser.parse("db.u.replaceOne({}, {a: 1}, {upsert: true})") else { + Issue.record("Expected .write operation") + return + } + #expect(replaceKind == .replaceOne) + + guard case .write(let findKind, _, _, _, _) = + try MongoShellParser.parse("db.u.findOneAndUpdate({}, {$set: {a: 1}}, {upsert: true})") else { + Issue.record("Expected .write operation") + return + } + #expect(findKind == .findOneAndUpdate) + } + + @Test("a non-document third argument is rejected") + func testNonDocumentOptionsThrows() { + #expect(throws: MongoShellParseError.self) { + try MongoShellParser.parse("db.users.updateOne({}, {$set: {a: 1}}, true)") + } + } } diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 2bfec8734..17df16f1c 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -125,7 +125,17 @@ db.users.countDocuments({"role": "admin"}) **Chained methods**: `.sort()`, `.limit()` and `.skip()` chain onto `find` and onto `aggregate`. On an aggregation they become `$sort`, `$skip` and `$limit` stages appended to the pipeline, in that order, which is what the cursor methods mean. Chaining onto a call that returns no cursor, such as `insertOne`, is an error, and so is a method the parser does not recognise. Nothing is dropped silently. -**Autocomplete**: The editor suggests collections, methods, field names, and the `$` operators that are valid at the cursor. See [Autocomplete](/features/autocomplete#mongodb). +**Write options**: `updateOne`, `updateMany`, `replaceOne` and `findOneAndUpdate` take a third options document. `upsert`, `arrayFilters` and `hint` are passed to the server. The option used to be dropped without a word, so an upsert quietly did nothing. + +```javascript +db.users.updateOne({_id: 1}, {"$set": {"active": true}}, {"upsert": true}) +``` + +The result reports `modifiedCount` and `upsertedCount`. + +**Formatting**: `Cmd+Shift+F` lays out filters and pipelines by nesting depth. Short documents stay on one line; anything nested or long is expanded. It used to run the SQL formatter over MQL. + +**Autocomplete**: The editor suggests collections, methods, field names including nested paths such as `address.city`, and the `$` operators that are valid at the cursor. See [Autocomplete](/features/autocomplete#mongodb). **Supported methods**: collection-level `find`, `findOne`, `aggregate`, `countDocuments`/`count`, `insertOne`/`insertMany`, `updateOne`/`updateMany`, `replaceOne`, `deleteOne`/`deleteMany`, `findOneAndUpdate`/`findOneAndReplace`/`findOneAndDelete`, `createIndex`, `dropIndex`, `drop`; database-level `getCollectionNames`/`listCollections`, `createCollection`, `dropDatabase`, `version`, `stats`. Anything else goes through `db.runCommand({...})` or `db.adminCommand({...})`. Unlisted shell methods (for example `distinct` or `getUsers`) return an unsupported-method error. diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 808d24ed9..74cdddd6e 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -147,7 +147,9 @@ db.orders.aggregate([{ $group: { | // $sum, $avg, $first, $push, and the expr The operator sets are kept apart on purpose. Offering `$match` inside a filter document, or `$gte` where a pipeline stage belongs, is worse than offering nothing. `$set` and `$unset` mean different things as an update operator and as a pipeline stage, and the popup describes whichever one applies at the cursor. -Field names come from the same sampled document schema the sidebar and Structure tab use. Nothing in the query is executed to build the list. +Field names come from a sample of the collection's documents and include nested paths, so a document with `address: { city, zip }` suggests `address`, `address.city` and `address.zip`. Objects inside an array contribute paths too. Shallower fields sort first. Nothing in the query is executed to build the list. + +The sample is cached per collection and cleared when you switch database or refresh the connection. Completion is suppressed inside comments, and braces or brackets inside a string literal do not count as opening a document.