From 346884cff6c53fa4155de06ecf67daf449fb2c91 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 26 Sep 2026 12:11:57 +0700 Subject: [PATCH 1/2] feat(plugin-mongodb): edit MongoDB documents as Extended JSON, found by their stored _id and saved under an exact guard --- CHANGELOG.md | 1 + .../MongoDBCapabilities.swift | 6 + .../MongoDBConnection+Documents.swift | 188 ++++++++++- .../MongoDBConnection+ScriptHelpers.swift | 14 +- .../MongoDBDocumentEditingError.swift | 67 ++++ .../MongoDBPluginDriver+Documents.swift | 31 +- .../MongoDBPluginDriver.swift | 10 +- .../MongoDocumentGuard.swift | 161 ++++++++++ .../MongoDocumentIdentity.swift | 45 +++ .../MongoDocumentPresentation.swift | 125 ++++++++ .../MongoDocumentReplacement.swift | 37 +++ .../MongoDocumentText.swift | 94 ++++++ .../MongoDocumentWritePlan.swift | 45 ++- .../MongoEditableDocument.swift | 159 ++++++++++ .../MongoExtendedJsonType.swift | 62 ++++ .../MongoScriptCursor.swift | 5 + .../MongoDBDriverPlugin/MongoScriptHost.swift | 4 +- .../MongoScriptResultBuilder.swift | 34 +- .../MongoWriteFailure.swift | 6 +- .../PluginQueryResult+MongoResult.swift | 53 ++++ Plugins/TableProPluginKit/DriverPlugin.swift | 7 +- .../PluginDatabaseDriver.swift | 13 +- .../PluginDocumentWrite.swift | 4 + .../TableProPluginKit/PluginQueryResult.swift | 8 + .../Coordinators/PaginationCoordinator.swift | 2 +- .../QueryExecutionCoordinator+Helpers.swift | 6 +- TablePro/Core/Database/DatabaseDriver.swift | 6 + .../DatabaseManager+DocumentWrite.swift | 22 ++ TablePro/Core/Database/DocumentEditing.swift | 77 ++++- TablePro/Core/Menu/EditMenuBuilder.swift | 4 + .../Core/Plugins/PluginDriverAdapter.swift | 5 + ...nSplitViewController+EditMenuActions.swift | 4 + ...inSplitViewController+MenuValidation.swift | 20 +- .../Core/Services/Query/QueryExecutor.swift | 12 +- TablePro/Models/Query/QueryResult.swift | 3 + TablePro/Models/Query/TableRows.swift | 42 ++- TablePro/Resources/Localizable.xcstrings | 48 +++ .../Main/Child/DataTabGridDelegate.swift | 13 +- .../Child/DocumentEditorPresentation.swift | 94 ++++++ .../Main/Child/DocumentEditorSheet.swift | 89 ++++-- ...inContentCoordinator+DocumentEditing.swift | 43 ++- .../MainContentCoordinator+QueryHelpers.swift | 6 +- .../MainContentCommandActions+Documents.swift | 11 + .../Main/MainContentCommandActions.swift | 9 + .../Views/Main/MainContentCoordinator.swift | 3 +- .../Core/Menu/MainMenuBuilderTests.swift | 43 +++ .../Core/Plugins/PluginRowLocatorTests.swift | 91 ++++++ .../Query/TableRowsRowLocatorTests.swift | 79 +++++ .../Plugins/MongoDocumentGuardTests.swift | 185 +++++++++++ .../Plugins/MongoDocumentIdentityTests.swift | 99 ++++++ .../MongoDocumentPresentationTests.swift | 116 +++++++ .../MongoDocumentReplacementTests.swift | 94 ++++++ .../Plugins/MongoDocumentWritePlanTests.swift | 61 +++- .../Plugins/MongoEditableDocumentTests.swift | 277 +++++++++++++++++ .../Plugins/MongoResultRebuildTests.swift | 59 ++++ .../Plugins/MongoWriteFailureTests.swift | 23 ++ .../DocumentEditingAvailabilityTests.swift | 129 ++++++++ .../DocumentEditorPresentationTests.swift | 93 ++++++ docs/databases/mongodb.mdx | 17 +- project.yml | 9 + scripts/check-mongodb-document-guard.sh | 293 ++++++++++++++++++ 61 files changed, 3233 insertions(+), 133 deletions(-) create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift create mode 100644 Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift create mode 100644 Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift create mode 100644 TablePro/Views/Main/Child/DocumentEditorPresentation.swift create mode 100644 TableProTests/Core/Plugins/PluginRowLocatorTests.swift create mode 100644 TableProTests/Models/Query/TableRowsRowLocatorTests.swift create mode 100644 TableProTests/Plugins/MongoDocumentGuardTests.swift create mode 100644 TableProTests/Plugins/MongoDocumentIdentityTests.swift create mode 100644 TableProTests/Plugins/MongoDocumentPresentationTests.swift create mode 100644 TableProTests/Plugins/MongoDocumentReplacementTests.swift create mode 100644 TableProTests/Plugins/MongoEditableDocumentTests.swift create mode 100644 TableProTests/Plugins/MongoResultRebuildTests.swift create mode 100644 TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift create mode 100644 TableProTests/Views/Main/DocumentEditorPresentationTests.swift create mode 100755 scripts/check-mongodb-document-guard.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index fd43cf0c69..d31251dc52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Insert Document…** for MongoDB collections, written as Extended JSON. (#3132) +- **Edit Document…** for MongoDB documents, written as Extended JSON. (#3132) - **Agent** mode: one session with the whole connection window, sessions to start and delete, and what each one ran. - Row previews and the query editor sized to the display on iPad and on iPhone Duo's inner display. - Table list and table browser side by side on iPad and on iPhone Duo's inner display. diff --git a/Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift b/Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift index 0805bc8d16..a78b61cf78 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift @@ -19,6 +19,12 @@ struct MongoDBCapabilities: Sendable, Equatable { major >= 4 } + /// The guard an edit is saved under uses `$convert`, which MongoDB 4.0 added. A server whose + /// version is not known is let through, so its own error reaches the user. + var supportsDocumentReplaceGuard: Bool { + self == .unknown || major >= 4 + } + static func parse(_ version: String?) -> MongoDBCapabilities { guard let version else { return .unknown } let parts = version.split(separator: ".") diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection+Documents.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection+Documents.swift index ad6251866f..0ddd0e6afb 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection+Documents.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection+Documents.swift @@ -9,7 +9,7 @@ import CLibMongoc import Foundation import TableProPluginKit -/// Whole-document writes for Insert Document. +/// Whole-document reads and writes for Insert Document and Edit Document. /// /// These go straight to libmongoc rather than through the shell. The shell turns a document into a /// JavaScript object on its way to the server, and JavaScript puts integer-like field names first, @@ -54,15 +54,154 @@ extension MongoDBConnection { throw MongoDBError.libmongocUnavailable #endif } + + /// The documents whose `_id` is byte for byte the one `identity` names, read from the primary so + /// an edit starts from the latest write, once the server and the namespace allow the edit. + /// + /// Every step, the version probe included, runs in one call on the connection's own queue, so + /// none of it blocks the caller's thread and a cancel that lands between steps stops the next. + func readStoredDocuments( + database: String, + collection: String, + identity: MongoDocumentIdentity + ) async throws -> [MongoStoredDocument] { + #if canImport(CLibMongoc) + let options = MongoEditableDocument.readOptions(maxTimeMS: effectiveMaxTimeMS(background: false)) + let namespaceCommand = MongoEditableDocument.namespaceTypeCommand(for: collection) + return try await readOnClient { [self] client in + try MongoEditableDocument.readStored( + serverVersion: serverVersion, + listCollectionsReply: { + try runCommandSync(client: client, command: namespaceCommand, database: database).first + }, + storedDocuments: { + try exactMatches( + client: client, + database: database, + collection: collection, + identity: identity, + options: options + ) + }, + checkCancelled: checkCancelled + ) + } + #else + throw MongoDBError.libmongocUnavailable + #endif + } + + /// Replaces the document `filter` matches and answers how many it matched, which is 0 when the + /// stored document is no longer the one the edit started from. + /// + /// libmongoc refuses an empty field name before sending unless `validate` is off, and the server + /// still refuses a top-level `$` field. The guard's answer has to be known, so a collection + /// whose write concern asks for no acknowledgement is asked for one here. + func replaceDocument(database: String, collection: String, filter: String, replacement: String) async throws -> Int64 { + #if canImport(CLibMongoc) + try await onClient { [self] client in + let filterBson = try parsedBson(filter) + defer { bson_destroy(filterBson) } + let replacementBson = try parsedBson(replacement) + defer { bson_destroy(replacementBson) } + guard MongoLibbsonCodec.size(of: filterBson) + MongoLibbsonCodec.size(of: replacementBson) + <= MongoEditableDocument.commandSizeLimit else { + throw MongoDBDocumentEditingError.tooLarge + } + let handle = try getCollection(client, database: database, collection: collection) + defer { mongoc_collection_destroy(handle) } + let isAcknowledged = mongoc_write_concern_is_acknowledged(mongoc_collection_get_write_concern(handle)) + let optionsBson = try parsedBson(Self.replaceOptions(acknowledged: isAcknowledged)) + defer { bson_destroy(optionsBson) } + guard let reply = bson_new() else { throw MongoDBError.connectionFailed } + defer { bson_destroy(reply) } + var error = bson_error_t() + try checkCancelled() + guard mongoc_collection_replace_one(handle, filterBson, replacementBson, optionsBson, reply, &error) else { + if let failure = (try? canonicalText(of: reply)).flatMap(MongoWriteFailure.read(fromReply:)) { + throw MongoDBError(code: failure.code, message: failure.message) + } + throw makeError(error) + } + return try matchedCount(in: reply) + } + #else + throw MongoDBError.libmongocUnavailable + #endif + } + + private static func replaceOptions(acknowledged: Bool) -> String { + let base = #""collation":{"locale":"simple"},"validate":false"# + return acknowledged ? "{\(base)}" : #"{\#(base),"writeConcern":{"w":1}}"# + } } #if canImport(CLibMongoc) +/// libbson's own reading of Extended JSON, which is the only authority on what a text stores. +struct MongoLibbsonCodec: MongoDocumentCodec { + func isSameDocument(_ text: String, asCanonical canonical: String) -> Bool { + guard let stored = Self.parse(canonical) else { return false } + defer { bson_destroy(stored) } + return Self.reads(text, as: stored) + } + + func bsonSize(of json: String) -> Int? { + guard let bson = Self.parse(json) else { return nil } + defer { bson_destroy(bson) } + return Self.size(of: bson) + } + + /// Whether `text` reads back as exactly `stored`. Text libbson cannot read is not the same. + static func reads(_ text: String, as stored: OpaquePointer) -> Bool { + guard let reread = parse(text) else { return false } + defer { bson_destroy(reread) } + return bson_equal(reread, stored) + } + + /// Every BSON document opens with its own length as a little-endian int32. + static func size(of bson: OpaquePointer) -> Int { + guard let data = bson_get_data(bson) else { return 0 } + let length = UnsafeRawPointer(data).loadUnaligned(as: Int32.self) + return Int(Int32(littleEndian: length)) + } + + private static func parse(_ json: String) -> OpaquePointer? { + var error = bson_error_t() + return json.withCString { bson_new_from_json($0, -1, &error) } + } +} + fileprivate extension MongoDBConnection { - /// On a thread of its own rather than the cooperative pool, since `withClientSync` blocks until - /// the connection's queue runs the call. + func matchedCount(in reply: OpaquePointer) throws -> Int64 { + let text = try canonicalText(of: reply) + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let matched = MongoScriptJson.numeric(object["matchedCount"]) else { + throw MongoDBError(code: 0, message: MongoScriptText.writeRefused(code: 0)) + } + return matched + } + func onClient(_ body: @escaping @Sendable (OpaquePointer) throws -> T) async throws -> T { beginScriptRun() - return try await pluginDispatchAsync(on: .global(qos: .userInitiated)) { [self] in + return try await onClientQueue(body) + } + + /// A read the app may cancel. Clearing the latch as the read starts also clears a cancel the app + /// sent a moment earlier, so the task is asked after the clear: a task is marked cancelled before + /// the app's cancel reaches the latch. + func readOnClient(_ body: @escaping @Sendable (OpaquePointer) throws -> T) async throws -> T { + beginScriptRun() + try Task.checkCancellation() + return try await onClientQueue(body) + } + + /// On a thread of its own rather than the cooperative pool, since `withClientSync` blocks until + /// the connection's queue runs the call. + private func onClientQueue( + _ body: @escaping @Sendable (OpaquePointer) throws -> T + ) async throws -> T { + try await pluginDispatchAsync(on: .global(qos: .userInitiated)) { [self] in try withClientSync { client in try checkCancelled() return try body(client) @@ -70,6 +209,47 @@ fileprivate extension MongoDBConnection { } } + /// Read under the collection's own collation, which is what lets a string `_id` use its index. + /// A lenient match is read past rather than kept, and the read stops at the second exact one. + func exactMatches( + client: OpaquePointer, + database: String, + collection: String, + identity: MongoDocumentIdentity, + options: String + ) throws -> [MongoStoredDocument] { + let filterBson = try parsedBson(identity.filter) + defer { bson_destroy(filterBson) } + let optionsBson = try parsedBson(options) + defer { bson_destroy(optionsBson) } + let session = attachCancellableSession(client: client, opts: optionsBson) + defer { + if let session { + releaseSessionLsid() + mongoc_client_session_destroy(session) + } + } + let handle = try getCollection(client, database: database, collection: collection) + defer { mongoc_collection_destroy(handle) } + let primary = mongoc_read_prefs_new(MONGOC_READ_PRIMARY) + defer { mongoc_read_prefs_destroy(primary) } + guard let cursor = mongoc_collection_find_with_opts(handle, filterBson, optionsBson, primary) else { + throw MongoDBError(code: 0, message: MongoScriptText.cursorFailed) + } + defer { mongoc_cursor_destroy(cursor) } + var matches = MongoExactMatches(identity: identity) + var pointer: OpaquePointer? + while !matches.isDecided, mongoc_cursor_next(cursor, &pointer) { + try checkCancelled() + guard let stored = pointer else { continue } + let canonical = try canonicalText(of: stored) + matches.consider(canonical) { MongoLibbsonCodec.reads(canonical, as: stored) } + } + var error = bson_error_t() + if mongoc_cursor_error(cursor, &error) { throw makeError(error) } + return matches.documents + } + func parsedBson(_ json: String) throws -> OpaquePointer { var error = bson_error_t() guard let bson = json.withCString({ bson_new_from_json($0, -1, &error) }) else { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift index a8ab73b585..86139e27cc 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift @@ -12,16 +12,28 @@ struct MongoScriptDocumentBatch: Sendable { var json: [String] var isTruncated: Bool + /// Whether these are whole documents as stored, which is what makes each one's `_id` a locator + /// the grid can hand back to edit it. A projection or a pipeline builds documents of its own. + var holdsStoredDocuments = false + static let empty = MongoScriptDocumentBatch(json: [], isTruncated: false) var jsonArray: String { "[\(json.joined(separator: ","))]" } var dictionaries: [[String: Any]] { + readRows().map(\.fields) + } + + /// Each document read once, with its locator taken from the same text, so a document that + /// cannot be read drops out of both and the two stay in step. + func readRows() -> [(fields: [String: Any], locator: String?)] { json.compactMap { document in guard let data = document.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data), let dictionary = object as? [String: Any] else { return nil } - return MongoDBConnection.unwrapExtendedJson(dictionary) as? [String: Any] ?? dictionary + let fields = MongoDBConnection.unwrapExtendedJson(dictionary) as? [String: Any] ?? dictionary + let locator = holdsStoredDocuments ? MongoDocumentIdentity.locator(inDocument: document) : nil + return (fields, locator) } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift b/Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift new file mode 100644 index 0000000000..00ab0f8441 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Why a document cannot be opened for editing or saved, in words for the user. +enum MongoDBDocumentEditingError: Error, Equatable, LocalizedError { + case unsupportedOperation + case unknownDocument + case documentChanged + case ambiguousIdentity + case identityChanged + case missingIdentity + case inexactAsText + case emptyTimestamp(String) + case tooLarge + case serverTooOld + case view + case timeSeries + case notACollection(String) + + var errorDescription: String? { + switch self { + case .unsupportedOperation: + return String(localized: "MongoDB cannot make this change to a document.") + case .unknownDocument: + return String(localized: "This row does not name a stored document.") + case .documentChanged: + return String( + localized: "The document changed on the server after it was opened, so nothing was saved. Copy your text, then open it again." + ) + case .ambiguousIdentity: + return String(localized: "More than one document has this _id, so it cannot be edited here.") + case .identityChanged: + return String( + localized: "The _id cannot change. Put the original _id back, or insert a new document instead." + ) + case .missingIdentity: + return String(localized: "This document has no _id, so it cannot be edited here.") + case .inexactAsText: + return String( + localized: "This document holds a value text cannot write back exactly, such as a subdocument like {\"$numberInt\": \"5\"}." + ) + case .emptyTimestamp(let field): + return String( + format: String( + localized: "The top-level field \u{201C}%@\u{201D} holds Timestamp(0, 0), which MongoDB replaces with the current time on every save." + ), + field + ) + case .tooLarge: + return String( + localized: "This document is too large to edit here. Saving it would send more than 16 MB to the server." + ) + case .serverTooOld: + return String(localized: "Editing a document needs MongoDB 4.0 or later.") + case .view: + return String(localized: "This is a view, so its documents cannot be edited. Edit them in the collection the view reads.") + case .timeSeries: + return String( + localized: "This is a time-series collection, which cannot replace a single document, so its documents cannot be edited here." + ) + case .notACollection(let type): + return String( + format: String(localized: "MongoDB lists this as a \u{201C}%@\u{201D} rather than a collection, so its documents cannot be edited here."), + type + ) + } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver+Documents.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver+Documents.swift index 5dcb4a46b0..3aa88cdd9c 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver+Documents.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver+Documents.swift @@ -8,16 +8,39 @@ import TableProPluginKit extension MongoDBPluginDriver { func documentWriteStatement(_ write: PluginDocumentWrite) throws -> String? { - try documentWritePlan(write).statement + try documentWritePlan(write)?.statement } + /// The plan is built on a queue of its own, since an edit reads and compares the whole document + /// and the caller may be the main actor. func executeDocumentWrite(_ write: PluginDocumentWrite) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected } - let plan = try documentWritePlan(write) - try await conn.insertDocument(database: currentDb, collection: write.table, document: plan.document) + let plan = try await pluginDispatchAsync(on: .global(qos: .userInitiated)) { [self] in + try documentWritePlan(write) + } + guard let plan else { return } + switch plan.write { + case .insert(let document): + try await conn.insertDocument(database: currentDb, collection: write.table, document: document) + case .replace(let filter, let replacement): + let matched = try await conn.replaceDocument( + database: currentDb, collection: write.table, filter: filter, replacement: replacement + ) + guard matched > 0 else { throw MongoDBDocumentEditingError.documentChanged } + } } - private func documentWritePlan(_ write: PluginDocumentWrite) throws -> MongoDocumentWritePlan { + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? { + guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected } + let identity = try MongoDocumentIdentity(locator: locator) + let stored = try await conn.readStoredDocuments(database: currentDb, collection: table, identity: identity) + try Task.checkCancellation() + return try await pluginDispatchAsync(on: .global(qos: .userInitiated)) { + try MongoEditableDocument.text(for: identity, among: stored, codec: MongoLibbsonCodec()) + } + } + + private func documentWritePlan(_ write: PluginDocumentWrite) throws -> MongoDocumentWritePlan? { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected } return try MongoDocumentWritePlan.make( collection: write.table, diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 5c6da17ebe..a0bcdd87ef 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -264,15 +264,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let rowCap, MongoDBFindLimitPolicy.isTruncated(rowCount: result.rows.count, rowCap: rowCap) else { return result } - return PluginQueryResult( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - rows: Array(result.rows.prefix(rowCap)), - rowsAffected: result.rowsAffected, - executionTime: result.executionTime, - isTruncated: true, - statusMessage: result.statusMessage - ) + return result.capped(to: rowCap) } private func mapExecutionError(_ error: Error) -> Error { diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift new file mode 100644 index 0000000000..b2a1b96901 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift @@ -0,0 +1,161 @@ +import Foundation + +/// The filter that lets a replacement through only while the stored document is exactly the one the +/// user opened. +/// +/// A plain match is not equality: `null` matches a missing field, `5` matches `[5, 6]`, and `5` +/// matches `NumberLong(5)`. So the filter compares the whole document with `$$ROOT`, which under the +/// simple collation covers every field name, their order and every value, and then compares a type +/// signature, which covers what `$eq` treats as equal: the numeric type, a decimal's trailing zeros, +/// the sign of a zero, and a string against a symbol. +/// +/// The signature is one `$map` per nesting level rather than one expression per field, so its size +/// grows with the depth of the document and not with its length. Every operator in it answers for +/// any input, because the server may evaluate the second `$and` operand even when the first is +/// false, and an operator that raises on what the document holds now would fail the save instead of +/// refusing it. +/// +/// Two values are compared by what they mean and nothing in an expression can see their bytes, so a +/// document holding one is refused rather than guarded: every NaN equals every other whatever its +/// sign and payload, and a JavaScript scope is compared the way a query compares, so `1` equals +/// `NumberLong(1)` inside it (both measured on MongoDB 7.0). +enum MongoDocumentGuard { + /// The deepest document whose filter libbson still reads. Its JSON reader stops at 98 levels + /// and the signature spends three of them on each level of the document, so a 28-level + /// document's filter reads and a 29-level one's does not (measured on libbson 1.28.1). + static let maximumDepth = 28 + + enum Refusal: Error, Equatable, LocalizedError { + case tooDeep + case unreadableValue + case notANumber + case codeWithScope + + var errorDescription: String? { + switch self { + case .tooDeep: + return String( + format: String(localized: "This document is nested more than %d levels deep, which is too deep to edit here."), + MongoDocumentGuard.maximumDepth + ) + case .unreadableValue: + return String(localized: "This document holds a value that cannot be edited as text.") + case .notANumber: + return String( + localized: "This document holds a NaN. MongoDB treats every NaN as the same value, so a save could not tell whether someone else changed it." + ) + case .codeWithScope: + return String( + localized: "This document holds JavaScript code with a scope. MongoDB compares a scope by value, so a save could not tell whether someone else changed it." + ) + } + } + } + + static func filter(for original: MongoDocumentText) throws -> String { + let root = MongoDocumentText.Value.object(original.members) + let depth = try self.depth(of: root) + guard depth <= maximumDepth else { throw Refusal.tooDeep } + guard let identity = original.value(of: MongoDocumentIdentity.field) else { + throw MongoDBDocumentEditingError.missingIdentity + } + let expected = try expectedSignature(ofContainer: root, depth: depth).compactText + let wholeDocument = #"{"$eq":["$$ROOT",{"$literal":\#(root.compactText)}]}"# + let types = #"{"$eq":[\#(signature(of: rootVariable, depth: depth)),{"$literal":\#(expected)}]}"# + return #"{"_id":\#(identity.compactText),"$expr":{"$and":[\#(wholeDocument),\#(types)]}}"# + } + + /// How many containers deep a value goes. A type marker such as `{"$oid": "…"}` is one value, + /// not a subdocument. + static func depth(of value: MongoDocumentText.Value) throws -> Int { + guard let type = MongoExtendedJsonType.name(of: value) else { throw Refusal.unreadableValue } + guard type == "object" || type == "array" else { return 0 } + let deepest = try children(of: value).map { try depth(of: $0) }.max() ?? 0 + return deepest + 1 + } + + // MARK: - Server side + + private static let rootVariable = #""$$ROOT""# + private static let elementVariable = #""$$this""# + + /// The signature of a container: one entry per child, in order. Above the last level an entry + /// is the child's own signature paired with the signature of its children, which is empty for a + /// value that is not a container. + private static func signature(of value: String, depth: Int) -> String { + let entry = depth > 1 + ? "[\(leafSignature(of: elementVariable)),\(signature(of: elementVariable, depth: depth - 1))]" + : leafSignature(of: elementVariable) + return #"{"$map":{"input":\#(childValues(of: value)),"in":\#(entry)}}"# + } + + /// A container's children as an array, and an empty array for anything else, so the `$map` + /// over it never raises whatever the field holds now. + private static func childValues(of value: String) -> String { + let fields = #"{"$objectToArray":{"$cond":[{"$eq":[{"$type":\#(value)},"object"]},\#(value),{}]}}"# + return #"{"$cond":[{"$isArray":\#(value)},\#(value),{"$map":{"input":\#(fields),"in":"$$this.v"}}]}"# + } + + /// The `$type` name, and for a decimal or a zero double also its text, which is the only way + /// to tell `1.0` from `1.00` and `0.0` from `-0.0`. + private static func leafSignature(of value: String) -> String { + let type = #"{"$type":\#(value)}"# + let isZeroDouble = #"{"$and":[{"$eq":[\#(type),"double"]},{"$eq":[\#(value),0]}]}"# + let needsText = #"{"$or":[{"$eq":[\#(type),"decimal"]},\#(isZeroDouble)]}"# + let text = #"{"$convert":{"input":\#(value),"to":"string","onError":"","onNull":""}}"# + return #"{"$cond":[\#(needsText),[\#(type),\#(text)],\#(type)]}"# + } + + // MARK: - What the server should answer + + private static func expectedSignature( + ofContainer value: MongoDocumentText.Value, + depth: Int + ) throws -> MongoDocumentText.Value { + let entries = try children(of: value).map { child -> MongoDocumentText.Value in + let leaf = try expectedLeafSignature(of: child) + guard depth > 1 else { return leaf } + let nested = MongoExtendedJsonType.isContainer(child) + ? try expectedSignature(ofContainer: child, depth: depth - 1) + : .array([]) + return .array([leaf, nested]) + } + return .array(entries) + } + + /// Every value but the document itself passes through here, so this is where a value the + /// signature cannot describe is refused. + private static func expectedLeafSignature(of value: MongoDocumentText.Value) throws -> MongoDocumentText.Value { + guard let type = MongoExtendedJsonType.name(of: value) else { throw Refusal.unreadableValue } + guard type != "javascriptWithScope" else { throw Refusal.codeWithScope } + guard case .object(let members) = value, members.count == 1, let member = members.first, + case .string(let text) = member.value else { + return .string(type) + } + switch member.key { + case "$numberDecimal": + guard text != notANumberText else { throw Refusal.notANumber } + return .array([.string(type), .string(text)]) + case "$numberDouble": + guard text != notANumberText else { throw Refusal.notANumber } + guard let number = Double(text), number == 0 else { return .string(type) } + return .array([.string(type), .string(number.sign == .minus ? "-0" : "0")]) + default: + return .string(type) + } + } + + /// How canonical Extended JSON writes every NaN, double or decimal, whatever its sign and payload. + private static let notANumberText = "NaN" + + private static func children(of value: MongoDocumentText.Value) -> [MongoDocumentText.Value] { + switch value { + case .array(let elements): + return elements + case .object(let members) where MongoExtendedJsonType.name(of: value) == "object": + return members.map(\.value) + case .object, .string, .number, .literal: + return [] + } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift new file mode 100644 index 0000000000..8ddf36f75f --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Which stored document a grid row is: the compact canonical Extended JSON of its `_id`. +/// +/// The grid shows an `_id` as display text, and display text cannot say which BSON value it came +/// from: an ObjectId and a string with the same hex read the same, and so do `1` and +/// `NumberLong(1)`. So the locator is taken from the document itself when the driver reads it, and +/// the document is found again by comparing that text byte for byte, never by what a query matches. +/// A query is lenient: `{_id: 1}` finds a stored double `1`, and a collection's collation finds +/// `"ABC"` for `"abc"`. +struct MongoDocumentIdentity: Equatable, Sendable { + static let field = "_id" + + let locator: String + + /// The locator of a document read as canonical Extended JSON, or nil when it has no `_id`. + static func locator(inDocument json: String) -> String? { + MongoDocumentText.topLevelValue(named: field, in: json)?.compactText + } + + /// Reads a locator strictly, as one value and nothing else, since it comes back from outside + /// the driver and becomes part of a query. + init(locator text: String) throws { + guard let document = try? MongoDocumentText(parsing: #"{"\#(Self.field)":\#(text)}"#), + document.members.count == 1, + let member = document.members.first, + member.key == Self.field, + !MongoExtendedJsonType.isQueryOperator(member.value) else { + throw MongoDBDocumentEditingError.unknownDocument + } + locator = member.value.compactText + } + + var filter: String { + #"{"\#(Self.field)":\#(locator)}"# + } + + /// Whether a stored document, as canonical Extended JSON, is the one this names. Compared as + /// bytes, because Swift's string equality treats two Unicode spellings of one character as + /// equal and the server does not. + func identifies(_ storedJson: String) -> Bool { + guard let stored = Self.locator(inDocument: storedJson) else { return false } + return stored.utf8.elementsEqual(locator.utf8) + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift new file mode 100644 index 0000000000..ee3b24cfe9 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift @@ -0,0 +1,125 @@ +import Foundation + +/// A stored document as text a person edits, which reads back as exactly the same document. +/// +/// Canonical Extended JSON is exact and unreadable: every number is a string in a wrapper. Relaxed +/// Extended JSON reads well and is not exact: a 64-bit integer small enough for 32 bits comes back +/// as a 32-bit one, and libbson cannot read back the date text it writes for years past 9999. +/// So each value is written the relaxed way only where that reads back as the same type and value, +/// and keeps its canonical wrapper everywhere else. +enum MongoDocumentPresentation { + static func editableText(_ document: MongoDocumentText) -> String { + var output = "" + write(readable(.object(document.members)), into: &output, indent: 0) + return output + } + + /// The canonical form, indented, for a document whose readable form does not read back as the + /// same document. + static func prettyCanonical(_ document: MongoDocumentText) -> String { + var output = "" + write(.object(document.members), into: &output, indent: 0) + return output + } + + static func readable(_ value: MongoDocumentText.Value) -> MongoDocumentText.Value { + switch value { + case .object(let members): + if let relaxed = relaxedScalar(members) { return relaxed } + if members.first?.key.hasPrefix("$") == true { return value } + return .object(members.map { MongoDocumentText.Member(key: $0.key, value: readable($0.value)) }) + case .array(let elements): + return .array(elements.map(readable)) + case .string, .number, .literal: + return value + } + } + + private static func relaxedScalar(_ members: [MongoDocumentText.Member]) -> MongoDocumentText.Value? { + guard members.count == 1, let member = members.first else { return nil } + switch (member.key, member.value) { + case ("$numberInt", .string(let text)): + guard Int32(text) != nil else { return nil } + return .number(text) + case ("$numberLong", .string(let text)): + guard let value = Int64(text), Int32(exactly: value) == nil else { return nil } + return .number(text) + case ("$numberDouble", .string(let text)): + guard let value = Double(text), value.isFinite else { return nil } + let shortest = value.description + guard shortest.rangeOfCharacter(from: fractionMarkers) != nil, + Double(shortest)?.bitPattern == value.bitPattern else { return nil } + return .number(shortest) + case ("$date", .object(let wrapped)): + guard wrapped.count == 1, let millis = wrapped.first, + millis.key == "$numberLong", case .string(let text) = millis.value, + let milliseconds = Int64(text), + let iso = isoDate(milliseconds: milliseconds) else { return nil } + return .object([MongoDocumentText.Member(key: "$date", value: .string(iso))]) + default: + return nil + } + } + + private static let fractionMarkers = CharacterSet(charactersIn: ".eE") + + /// The relaxed form only covers 1970 through 9999, which is the range whose ISO text libbson + /// both writes and reads. + private static let latestRelaxedDate: Int64 = 253_402_300_799_999 + + static func isoDate(milliseconds: Int64) -> String? { + guard milliseconds >= 0, milliseconds <= latestRelaxedDate else { return nil } + let seconds = milliseconds / 1_000 + let fraction = milliseconds % 1_000 + let date = Date(timeIntervalSince1970: TimeInterval(seconds)) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? calendar.timeZone + let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + guard let year = parts.year, let month = parts.month, let day = parts.day, + let hour = parts.hour, let minute = parts.minute, let second = parts.second else { return nil } + let base = String(format: "%04d-%02d-%02dT%02d:%02d:%02d", year, month, day, hour, minute, second) + return fraction == 0 ? base + "Z" : base + String(format: ".%03lldZ", fraction) + } + + private static func write(_ shown: MongoDocumentText.Value, into output: inout String, indent: Int) { + switch shown { + case .object(let members) where members.isEmpty: + output += "{}" + case .array(let elements) where elements.isEmpty: + output += "[]" + case .object(let members) where isInlineWrapper(members): + output += shown.compactText + case .object(let members): + output += "{\n" + for (index, member) in members.enumerated() { + output += String(repeating: " ", count: indent + 1) + output += MongoDocumentText.quoted(member.key) + ": " + write(member.value, into: &output, indent: indent + 1) + output += index == members.count - 1 ? "\n" : ",\n" + } + output += String(repeating: " ", count: indent) + "}" + case .array(let elements): + output += "[\n" + for (index, element) in elements.enumerated() { + output += String(repeating: " ", count: indent + 1) + write(element, into: &output, indent: indent + 1) + output += index == elements.count - 1 ? "\n" : ",\n" + } + output += String(repeating: " ", count: indent) + "]" + case .string, .number, .literal: + output += shown.compactText + } + } + + /// `{"$oid": "…"}` is one value, so it stays on one line. + private static func isInlineWrapper(_ members: [MongoDocumentText.Member]) -> Bool { + guard let first = members.first, first.key.hasPrefix("$") else { return false } + return members.allSatisfy { member in + switch member.value { + case .string, .number, .literal: return true + case .object(let inner): return inner.count <= 2 + case .array: return false + } + } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift new file mode 100644 index 0000000000..a5ade0f0a0 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift @@ -0,0 +1,37 @@ +import Foundation + +/// The document an edit replaces the stored one with, in the order the user wrote it. +/// +/// A whole-document replace is the only write that keeps the written order: `$set` adds new fields +/// in lexicographic order and reads a dotted name as a path. The stored `_id` goes first and cannot +/// change, and one the user left out is put back rather than read as a request to drop it. +struct MongoDocumentReplacement: Equatable, Sendable { + let document: MongoDocumentText + let changesDocument: Bool + + /// Both documents as canonical Extended JSON. + init(original: MongoDocumentText, edited: MongoDocumentText) throws { + let field = MongoDocumentIdentity.field + guard let identity = original.value(of: field) else { throw MongoDBDocumentEditingError.missingIdentity } + if let editedIdentity = edited.value(of: field), + !editedIdentity.compactText.utf8.elementsEqual(identity.compactText.utf8) { + throw MongoDBDocumentEditingError.identityChanged + } + let fields = edited.members.filter { !$0.key.utf8.elementsEqual(field.utf8) } + if let stamped = fields.first(where: { Self.isEmptyTimestamp($0.value) }) { + throw MongoDBDocumentEditingError.emptyTimestamp(stamped.key) + } + document = MongoDocumentText(members: [MongoDocumentText.Member(key: field, value: identity)] + fields) + changesDocument = !document.compactText.utf8.elementsEqual(original.compactText.utf8) + } + + /// `Timestamp(0, 0)` in a top-level field, which the server replaces with the current time + /// whenever it writes the document, so saving it would change a value nobody edited. + static func isEmptyTimestamp(_ value: MongoDocumentText.Value) -> Bool { + value.compactText == #"{"$timestamp":{"t":0,"i":0}}"# + } + + static func emptyTimestampField(in document: MongoDocumentText) -> String? { + document.members.first { isEmptyTimestamp($0.value) }?.key + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentText.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentText.swift index eab4400cbb..cb07334613 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDocumentText.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentText.swift @@ -88,6 +88,19 @@ struct MongoDocumentText: Equatable, Sendable { var compactText: String { Value.object(members).compactText } + + func value(of key: String) -> Value? { + members.first { $0.key.utf8.elementsEqual(key.utf8) }?.value + } + + /// One top-level field of a document's text, read without building the others. + /// + /// Every row of a result pays for this, so the fields before the one asked for are stepped over + /// rather than read. Nil when the text is not an object or has no such field. + static func topLevelValue(named name: String, in text: String) -> Value? { + var reader = Reader(text) + return try? reader.readTopLevelValue(named: Array(name.utf8)) + } } extension MongoDocumentText.Value { @@ -176,6 +189,87 @@ extension MongoDocumentText { } } + mutating func readTopLevelValue(named name: [UInt8]) throws -> Value? { + skipWhitespace() + guard peek == UInt8(ascii: "{") else { return nil } + index += 1 + skipWhitespace() + guard peek != UInt8(ascii: "}") else { return nil } + while true { + skipWhitespace() + guard peek == UInt8(ascii: "\"") else { throw malformed } + let key = try readString() + skipWhitespace() + guard peek == UInt8(ascii: ":") else { throw malformed } + index += 1 + if key.utf8.elementsEqual(name) { return try readValue(depth: 2) } + try skipValue() + skipWhitespace() + switch peek { + case UInt8(ascii: ","): + index += 1 + case UInt8(ascii: "}"): + return nil + default: + throw malformed + } + } + } + + private mutating func skipValue() throws { + skipWhitespace() + guard let byte = peek else { throw malformed } + switch byte { + case UInt8(ascii: "\""): + try skipString() + case UInt8(ascii: "{"), UInt8(ascii: "["): + try skipContainer() + default: + while let current = peek, !isValueEnd(current) { index += 1 } + } + } + + private mutating func skipContainer() throws { + var depth = 0 + while let byte = peek { + switch byte { + case UInt8(ascii: "\""): + try skipString() + continue + case UInt8(ascii: "{"), UInt8(ascii: "["): + depth += 1 + case UInt8(ascii: "}"), UInt8(ascii: "]"): + depth -= 1 + if depth == 0 { + index += 1 + return + } + default: + break + } + index += 1 + } + throw malformed + } + + private mutating func skipString() throws { + index += 1 + while let byte = peek { + index += 1 + if byte == UInt8(ascii: "\\") { + index += 1 + } else if byte == UInt8(ascii: "\"") { + return + } + } + throw malformed + } + + private func isValueEnd(_ byte: UInt8) -> Bool { + byte == UInt8(ascii: ",") || byte == UInt8(ascii: "}") || byte == UInt8(ascii: "]") + || byte == 0x20 || byte == 0x0A || byte == 0x0D || byte == 0x09 + } + private mutating func readObject(depth: Int) throws -> Value { index += 1 var members: [Member] = [] diff --git a/Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift b/Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift index 05f90cd327..c27f20fed0 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift @@ -6,26 +6,48 @@ import Foundation import TableProPluginKit -/// What Insert Document sends, and the shell statement that says so. +/// What Insert Document and Edit Document send, and the shell statement that says so. /// /// The statement is what the execution gate shows and query history keeps. The write itself goes -/// to libmongoc with the same document, so the two cannot disagree about what was sent. +/// to libmongoc with the same documents, so the two cannot disagree about what was sent. struct MongoDocumentWritePlan: Equatable, Sendable { - let document: String + enum Write: Equatable, Sendable { + case insert(document: String) + case replace(filter: String, replacement: String) + } + + /// Written into the statement because the replace runs under it: only the simple collation + /// compares strings byte for byte, which is what makes the guard exact. + static let replaceOptions = #"{"collation":{"locale":"simple"}}"# + + let write: Write let statement: String + /// Nil when an edit changes nothing, so there is nothing to write. + /// /// - Parameter canonicalize: libbson's reading of a text as canonical Extended JSON. It is /// passed in so the plan can be built without a connection. static func make( collection: String, operation: PluginDocumentWrite.Operation, canonicalize: (String) throws -> String - ) throws -> MongoDocumentWritePlan { + ) throws -> MongoDocumentWritePlan? { + let accessor = MongoCollectionAccessor.expression(for: collection) switch operation { case .insert(let text): let document = try canonicalDocument(text, canonicalize: canonicalize).compactText - let accessor = MongoCollectionAccessor.expression(for: collection) - return MongoDocumentWritePlan(document: document, statement: "\(accessor).insertOne(\(document))") + return MongoDocumentWritePlan(write: .insert(document: document), statement: "\(accessor).insertOne(\(document))") + case .replace(let originalText, let editedText): + let original = try canonicalDocument(originalText, canonicalize: canonicalize) + let edited = try canonicalDocument(editedText, canonicalize: canonicalize) + let replacement = try MongoDocumentReplacement(original: original, edited: edited) + guard replacement.changesDocument else { return nil } + let filter = try MongoDocumentGuard.filter(for: original) + let document = replacement.document.compactText + return MongoDocumentWritePlan( + write: .replace(filter: filter, replacement: document), + statement: "\(accessor).replaceOne(\(filter), \(document), \(replaceOptions))" + ) @unknown default: throw MongoDBDocumentEditingError.unsupportedOperation } @@ -41,14 +63,3 @@ struct MongoDocumentWritePlan: Equatable, Sendable { return try MongoDocumentText(parsing: try canonicalize(text)) } } - -enum MongoDBDocumentEditingError: Error, Equatable, LocalizedError { - case unsupportedOperation - - var errorDescription: String? { - switch self { - case .unsupportedOperation: - return String(localized: "MongoDB cannot make this change to a document.") - } - } -} diff --git a/Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift b/Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift new file mode 100644 index 0000000000..d337268d8f --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift @@ -0,0 +1,159 @@ +import Foundation + +/// libbson's answers the edit needs, passed in so the decisions can be made without a connection. +protocol MongoDocumentCodec: Sendable { + /// Whether `text` reads as exactly the document `canonical` holds, byte for byte in BSON. + func isSameDocument(_ text: String, asCanonical canonical: String) -> Bool + + /// The BSON size of a document written as Extended JSON, or nil when libbson cannot read it. + func bsonSize(of json: String) -> Int? +} + +/// A document the server returned for a locator, as canonical Extended JSON. +/// +/// `isRepresentable` is libbson's own answer to whether that text reads back as the same BSON. The +/// text alone cannot say: a stored subdocument `{"$numberInt": "5"}` prints and rereads as identical +/// text, and rereads as an int, and every NaN prints as `"NaN"` whatever payload it carries. +struct MongoStoredDocument: Equatable, Sendable { + let canonical: String + let isRepresentable: Bool +} + +/// The documents a read finds that are, byte for byte, the one a locator names. +/// +/// The read runs under the collection's collation, so it can also find documents whose `_id` only +/// collates or compares equal. One server holds at most one of those, because the `_id` index takes +/// the collection's collation (measured on 7.0: `"ABC"` beside `"abc"` is a duplicate key under +/// strength 2, and so is `NumberLong(1)` beside `1`). A sharded collection enforces that per shard, so +/// every shard can answer with one, and a read that stopped at a count would stop on them. The read +/// goes on until it has two exact matches, which is all the ambiguity check needs. +struct MongoExactMatches { + let identity: MongoDocumentIdentity + private(set) var documents: [MongoStoredDocument] = [] + + init(identity: MongoDocumentIdentity) { + self.identity = identity + } + + var isDecided: Bool { + documents.count > 1 + } + + /// `isRepresentable` is asked only of an exact match, since it reads the whole document again. + mutating func consider(_ canonical: String, isRepresentable: () -> Bool) { + guard !isDecided, identity.identifies(canonical) else { return } + documents.append(MongoStoredDocument(canonical: canonical, isRepresentable: isRepresentable())) + } +} + +/// Opens a stored document as the text Edit Document shows, or says why it cannot be edited. +enum MongoEditableDocument { + /// A write command may be 16 MB plus room for the command around it, and the guarded replace + /// carries the stored document, its signature and the replacement. + static let commandSizeLimit = 16 * 1_024 * 1_024 + + /// Nil when no stored document has the locator any more. + /// + /// The guard is built before anything else is asked, so a value it refuses is named as that + /// value: a NaN libbson cannot write back is refused for being a NaN, not for its payload. + static func text( + for identity: MongoDocumentIdentity, + among stored: [MongoStoredDocument], + codec: MongoDocumentCodec + ) throws -> String? { + let matches = stored.filter { identity.identifies($0.canonical) } + guard let match = matches.first else { return nil } + guard matches.count == 1 else { throw MongoDBDocumentEditingError.ambiguousIdentity } + + let document = try MongoDocumentText(parsing: match.canonical) + let filter = try MongoDocumentGuard.filter(for: document) + guard match.isRepresentable else { throw MongoDBDocumentEditingError.inexactAsText } + if let field = MongoDocumentReplacement.emptyTimestampField(in: document) { + throw MongoDBDocumentEditingError.emptyTimestamp(field) + } + try checkFits(filter: filter, canonical: match.canonical, codec: codec) + + let readable = MongoDocumentPresentation.editableText(document) + guard codec.isSameDocument(readable, asCanonical: match.canonical) else { + return MongoDocumentPresentation.prettyCanonical(document) + } + return readable + } + + /// The steps that read a document for editing: the server's version, what kind of namespace the + /// collection is, then the stored documents. + /// + /// They run as one call on the connection, because a call clears the connection's cancellation + /// latch as it starts and a cancel that lands during one step has to stop the next: the latch is + /// cleared once and asked before every step. A user who may read the collection but not list + /// collections is not stopped here; the server's own answer arrives at Save instead. + static func readStored( + serverVersion: () -> String?, + listCollectionsReply: () throws -> [String: Any]?, + storedDocuments: () throws -> [MongoStoredDocument], + checkCancelled: () throws -> Void + ) throws -> [MongoStoredDocument] { + try checkCancelled() + guard MongoDBCapabilities.parse(serverVersion()).supportsDocumentReplaceGuard else { + throw MongoDBDocumentEditingError.serverTooOld + } + try checkCancelled() + if let refusal = try namespaceRefusal(listCollectionsReply) { throw refusal } + try checkCancelled() + return try storedDocuments() + } + + private static func namespaceRefusal( + _ listCollectionsReply: () throws -> [String: Any]? + ) throws -> MongoDBDocumentEditingError? { + do { + guard let reply = try listCollectionsReply() else { return nil } + return namespaceRefusal(listCollectionsReply: reply) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + + /// No `limit`, because the documents the collation also finds would count towards it; see + /// `MongoExactMatches`. + static func readOptions(maxTimeMS: Int32?) -> String { + guard let maxTimeMS else { return "{}" } + return #"{"maxTimeMS":\#(maxTimeMS)}"# + } + + /// The `listCollections` command that says what kind of namespace a name is. + static func namespaceTypeCommand(for collection: String) -> String { + #"{"listCollections":1,"filter":{"name":\#(MongoDocumentText.quoted(collection))}}"# + } + + /// Only a plain collection replaces a document found by its `_id`. A view refuses every write, + /// and a time-series collection refuses a replace (code 72 on 7.0) and does not keep `_id` unique, + /// so each is refused when the document opens rather than after it was edited. A kind this does + /// not know is refused too. A reply that names no kind is let through, and the server's own + /// answer arrives at Save. + static func namespaceRefusal(listCollectionsReply reply: [String: Any]) -> MongoDBDocumentEditingError? { + guard let cursor = reply["cursor"] as? [String: Any], + let batch = cursor["firstBatch"] as? [[String: Any]], + let type = batch.first?["type"] as? String else { return nil } + switch type { + case "collection": + return nil + case "view": + return .view + case "timeseries": + return .timeSeries + default: + return .notACollection(type) + } + } + + /// Checked when the document opens rather than when it is saved, so nobody edits a document + /// that could never be written back. + static func checkFits(filter: String, canonical: String, codec: MongoDocumentCodec) throws { + guard let filterSize = codec.bsonSize(of: filter) else { throw MongoDocumentGuard.Refusal.tooDeep } + let documentSize = codec.bsonSize(of: canonical) ?? 0 + guard filterSize + documentSize <= commandSizeLimit else { throw MongoDBDocumentEditingError.tooLarge } + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift b/Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift new file mode 100644 index 0000000000..f5d46763d3 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift @@ -0,0 +1,62 @@ +import Foundation + +/// The name MongoDB's `$type` operator gives a value, read from the value's canonical Extended JSON. +/// +/// Canonical Extended JSON writes every typed value as a one-field object whose key starts with `$`, +/// so the key alone says the type. An object whose first key starts with `$` and is none of these is +/// an ordinary subdocument, which is what the server stores it as. +enum MongoExtendedJsonType { + static func name(of value: MongoDocumentText.Value) -> String? { + switch value { + case .array: + return "array" + case .string: + return "string" + case .literal(let literal): + return literalTypes[literal] + case .number: + return nil + case .object(let members): + return objectTypeName(members) + } + } + + static func isContainer(_ value: MongoDocumentText.Value) -> Bool { + let type = name(of: value) + return type == "object" || type == "array" + } + + /// A subdocument whose first field is an operator rather than a type marker, which a query + /// reads as a condition rather than a value to match. + static func isQueryOperator(_ value: MongoDocumentText.Value) -> Bool { + guard case .object(let members) = value, let first = members.first else { return false } + return first.key.hasPrefix("$") && name(of: value) == "object" + } + + private static let literalTypes = ["true": "bool", "false": "bool", "null": "null"] + + private static let wrapperTypes: [String: String] = [ + "$oid": "objectId", + "$symbol": "symbol", + "$numberInt": "int", + "$numberLong": "long", + "$numberDouble": "double", + "$numberDecimal": "decimal", + "$binary": "binData", + "$code": "javascript", + "$timestamp": "timestamp", + "$regularExpression": "regex", + "$dbPointer": "dbPointer", + "$date": "date", + "$minKey": "minKey", + "$maxKey": "maxKey", + "$undefined": "undefined" + ] + + private static func objectTypeName(_ members: [MongoDocumentText.Member]) -> String { + guard let first = members.first, let wrapped = wrapperTypes[first.key] else { return "object" } + if members.count == 1 { return wrapped } + if first.key == "$code", members.count == 2, members[1].key == "$scope" { return "javascriptWithScope" } + return "object" + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptCursor.swift b/Plugins/MongoDBDriverPlugin/MongoScriptCursor.swift index 8676a5265d..f1bd136976 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptCursor.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptCursor.swift @@ -43,6 +43,11 @@ final class MongoScriptCursor { return false } + /// A find with no projection returns documents exactly as they are stored. + var returnsWholeDocuments: Bool { + isFind && options.projection == nil + } + var filterJson: String { if case .find(let filter) = kind { return filter } return "{}" diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift index 637f5bc8c1..a0738728aa 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptHost.swift @@ -180,7 +180,9 @@ final class MongoScriptHost { /// straight to the grid without being marshalled twice. func drain(handle: Int) throws -> MongoScriptDocumentBatch { let cursor = try cursors.cursor(for: handle) - return try cursor.remaining { try load($0, ceiling: valueCeiling) } + var batch = try cursor.remaining { try load($0, ceiling: valueCeiling) } + batch.holdsStoredDocuments = cursor.returnsWholeDocuments + return batch } func cursorDescription(handle: Int) -> (collection: String, find: MongoScriptFindShape?)? { diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift index 274b1960e8..834f2eb7b9 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift @@ -24,11 +24,14 @@ enum MongoScriptResultBuilder { } if outcome.producedDocuments { + let rows = outcome.documents.readRows() let grid = build( - outcome.documents.dictionaries, + rows.map(\.fields), outcome.collection ?? "", outcome.documents.isTruncated - ).withRowsAffected(outcome.rowsAffected) + ) + .withRowsAffected(outcome.rowsAffected) + .withRowLocators(outcome.documents.holdsStoredDocuments ? rows.map(\.locator) : nil) guard !outcome.printedLines.isEmpty else { return grid } return grid.withStatus(printedSummary(outcome.printedLines)) } @@ -71,30 +74,3 @@ enum MongoScriptResultBuilder { return String(joined.prefix(400)) + "…" } } - -private extension PluginQueryResult { - func withRowsAffected(_ count: Int) -> PluginQueryResult { - guard count != rowsAffected else { return self } - return PluginQueryResult( - columns: columns, - columnTypeNames: columnTypeNames, - rows: rows, - rowsAffected: count, - executionTime: executionTime, - isTruncated: isTruncated, - statusMessage: statusMessage - ) - } - - func withStatus(_ message: String) -> PluginQueryResult { - PluginQueryResult( - columns: columns, - columnTypeNames: columnTypeNames, - rows: rows, - rowsAffected: rowsAffected, - executionTime: executionTime, - isTruncated: isTruncated, - statusMessage: message - ) - } -} diff --git a/Plugins/MongoDBDriverPlugin/MongoWriteFailure.swift b/Plugins/MongoDBDriverPlugin/MongoWriteFailure.swift index 45f3f3c8ef..e5cdcde65b 100644 --- a/Plugins/MongoDBDriverPlugin/MongoWriteFailure.swift +++ b/Plugins/MongoDBDriverPlugin/MongoWriteFailure.swift @@ -9,7 +9,8 @@ import Foundation /// matched nothing. /// /// A write-concern error arrives after the write itself was applied, so its message says so rather -/// than reading like a refusal. +/// than reading like a refusal. A raw command reply holds one as `writeConcernError`, and the reply +/// libmongoc builds for a CRUD call holds them as a `writeConcernErrors` array. struct MongoWriteFailure: Equatable, Sendable { let code: UInt32 let message: String @@ -22,7 +23,8 @@ struct MongoWriteFailure: Equatable, Sendable { if let writeErrors = reply["writeErrors"] as? [[String: Any]], let first = writeErrors.first { return entry(first) } - if let concernError = reply["writeConcernError"] as? [String: Any] { + let concernErrors = reply["writeConcernErrors"] as? [[String: Any]] + if let concernError = reply["writeConcernError"] as? [String: Any] ?? concernErrors?.first { let failure = entry(concernError) return MongoWriteFailure( code: failure.code, diff --git a/Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift b/Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift new file mode 100644 index 0000000000..4e825ed6de --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift @@ -0,0 +1,53 @@ +import Foundation +import TableProPluginKit + +/// The copies the MongoDB driver makes of a result on its way to the grid. +/// +/// Every copy goes through `rebuilt`, which carries the column metadata, the timing and the row +/// locators across. Rebuilding field by field is how a copy used to drop the timing, and dropping +/// the locators would leave every row of a table uneditable without a word. +extension PluginQueryResult { + func withRowsAffected(_ count: Int) -> PluginQueryResult { + guard count != rowsAffected else { return self } + return rebuilt(rowsAffected: count) + } + + func withStatus(_ message: String) -> PluginQueryResult { + rebuilt(statusMessage: message) + } + + /// Locators that do not pair one to one with the rows are dropped rather than trusted. + func withRowLocators(_ locators: [String?]?) -> PluginQueryResult { + var result = self + result.rowLocators = locators?.count == rows.count ? locators : nil + return result + } + + /// The first `rowCap` rows, marked truncated. + func capped(to rowCap: Int) -> PluginQueryResult { + guard rows.count > rowCap else { return self } + var result = rebuilt(rows: Array(rows.prefix(max(rowCap, 0))), isTruncated: true) + result.rowLocators = rowLocators.map { Array($0.prefix(max(rowCap, 0))) } + return result + } + + private func rebuilt( + rows: [[PluginCellValue]]? = nil, + rowsAffected: Int? = nil, + isTruncated: Bool? = nil, + statusMessage: String? = nil + ) -> PluginQueryResult { + var result = PluginQueryResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: rows ?? self.rows, + rowsAffected: rowsAffected ?? self.rowsAffected, + timing: timing, + isTruncated: isTruncated ?? self.isTruncated, + statusMessage: statusMessage ?? self.statusMessage, + columnMeta: columnMeta + ) + result.rowLocators = rowLocators + return result + } +} diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index bcf3675861..70d19e643a 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -72,9 +72,10 @@ public protocol DriverPlugin: TableProPlugin { static var supportsRenameDatabase: Bool { get } static var supportsRenameSchema: Bool { get } - /// Whether the engine stores whole documents and implements `documentWriteStatement` and - /// `executeDocumentWrite`, which is what offers Insert Document. Read from the loaded plugin, so - /// a binary built before these existed does not offer it. + /// Whether the engine stores whole documents and implements `documentWriteStatement`, + /// `executeDocumentWrite` and `fetchDocument`, which is what offers Insert Document and Edit + /// Document. Read from the loaded plugin, so a binary built before these existed does not offer + /// them. Edit Document is offered only on a row the driver gave a locator. static var supportsDocumentEditing: Bool { get } /// Whether a schema is something the engine makes from a statement. False on Oracle, where a diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 109867ce7f..4895251cde 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -273,8 +273,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// written as it stands. For an engine whose `DriverPlugin` sets `supportsDocumentEditing`. func documentWriteStatement(_ write: PluginDocumentWrite) throws -> String? - /// Performs the write `documentWriteStatement` described. + /// Performs the write `documentWriteStatement` described. An edit throws when the stored + /// document changed after `fetchDocument` read it, and writes nothing. func executeDocumentWrite(_ write: PluginDocumentWrite) async throws + + /// The stored document a row's locator names, as the text an edit starts from, or nil when no + /// document has that locator any more. `locator` is an entry of `PluginQueryResult.rowLocators` + /// this driver produced. Throws, with a message for the user, when the document cannot be + /// edited as text. + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult // Session contexts (optional, switchable session dimensions such as a warehouse or role) @@ -881,6 +888,10 @@ public extension PluginDatabaseDriver { throw PluginDriverUnsupportedOperation.writeDocument } + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? { + throw PluginDriverUnsupportedOperation.writeDocument + } + func dropDatabase(name: String) async throws { throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) diff --git a/Plugins/TableProPluginKit/PluginDocumentWrite.swift b/Plugins/TableProPluginKit/PluginDocumentWrite.swift index 8509407c16..c52d5b99a9 100644 --- a/Plugins/TableProPluginKit/PluginDocumentWrite.swift +++ b/Plugins/TableProPluginKit/PluginDocumentWrite.swift @@ -16,6 +16,10 @@ import Foundation public struct PluginDocumentWrite: Sendable, Equatable { public enum Operation: Sendable, Equatable { case insert(document: String) + /// Replaces a stored document with `edited`. `original` is the text `fetchDocument` + /// returned: it names the document and is what the edit is compared against, so a document + /// someone changed after it was fetched is refused rather than overwritten. + case replace(original: String, edited: String) } public let table: String diff --git a/Plugins/TableProPluginKit/PluginQueryResult.swift b/Plugins/TableProPluginKit/PluginQueryResult.swift index c660134850..88b6df3d18 100644 --- a/Plugins/TableProPluginKit/PluginQueryResult.swift +++ b/Plugins/TableProPluginKit/PluginQueryResult.swift @@ -11,6 +11,13 @@ public struct PluginQueryResult: Codable, Sendable { public let columnMeta: [PluginColumnInfo]? public let timing: PluginQueryTiming + /// For each row, text the driver that produced the result can find that row by again, or nil + /// for a row it cannot. The host never reads it and only hands a row's locator back to the + /// same driver, which is how a document store edits a document the grid shows only as display + /// text. Nil when the driver keeps none. Set after construction, so every published initializer + /// keeps its signature. + public var rowLocators: [String?]? + public init( columns: [String], columnTypeNames: [String], @@ -94,6 +101,7 @@ public struct PluginQueryResult: Codable, Sendable { columnMeta = try container.decodeIfPresent([PluginColumnInfo].self, forKey: .columnMeta) timing = try container.decodeIfPresent(PluginQueryTiming.self, forKey: .timing) ?? PluginQueryTiming(total: executionTime) + rowLocators = try container.decodeIfPresent([String?].self, forKey: .rowLocators) } public static let empty = PluginQueryResult( diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index a006ddb9f6..312a9646d7 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -379,7 +379,7 @@ final class PaginationCoordinator: ObservableObject { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let replaceDelta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.replace(rows: result.rows) + rows.replace(rows: result.rows, rowLocators: isTableTab ? result.rowLocators : nil) } parent.tabManager.mutate(at: idx) { tab in tab.execution.executionTime = result.executionTime diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 7e606166da..1dab247243 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -188,7 +188,8 @@ extension QueryExecutionCoordinator { anchor: StatementAnchor? = nil, timing: PluginQueryTiming? = nil, viewport: GridReloadIntent = .firstRow, - serverOutput: PluginServerOutput = .none + serverOutput: PluginServerOutput = .none, + rowLocators: [String?]? = nil ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -237,7 +238,8 @@ extension QueryExecutionCoordinator { generatedColumns: generatedColumns, rowMatchPolicy: resolved.rowMatchPolicy, hasAuthoritativeSchema: resolved.hasAuthoritativeSchema, - foreignKeysFetched: resolved.foreignKeysFetched + foreignKeysFetched: resolved.foreignKeysFetched, + rowLocators: rowLocators ) let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 7b24ba7927..8555824e72 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -282,6 +282,8 @@ protocol DatabaseDriver: AnyObject, Sendable { func executeDocumentWrite(_ write: PluginDocumentWrite) async throws + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? + func createSchemaStatements(_ definition: PluginSchemaDefinition) -> [String]? func renameSchemaStatements(name: String, to newName: String) -> [String]? @@ -559,6 +561,10 @@ extension DatabaseDriver { throw PluginDriverUnsupportedOperation.writeDocument } + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? { + throw PluginDriverUnsupportedOperation.writeDocument + } + func createSchemaStatements(_ definition: PluginSchemaDefinition) -> [String]? { nil } func renameSchemaStatements(name: String, to newName: String) -> [String]? { nil } diff --git a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift index e7c85778a2..d298c931a8 100644 --- a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift +++ b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift @@ -55,6 +55,28 @@ extension DatabaseManager { AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: scope.connectionId, scope: scope)) } + /// The stored document a row's locator names, read on the connection the tab's data lives on. + /// + /// Cancellable, so closing the sheet stops a read the server is still answering: it runs under + /// its own lease owner, which a cancel reaches and nothing else does. + func fetchDocument(locator: String, table: String, scope: DatabaseScope) async throws -> String? { + let owner = DriverLeaseOwner() + let connectionId = scope.connectionId + return try await withTaskCancellationHandler { + try await withScopedDriver( + scope: scope, + route: executionRoute(for: scope), + cancellation: .cancellableRead(owner) + ) { driver in + try await driver.fetchDocument(table: table, schema: scope.schema, locator: locator) + } + } onCancel: { + Task { @MainActor in + try? DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connectionId, delivery: .background) + } + } + } + private func recordDocumentWrite( _ statement: String, scope: DatabaseScope, diff --git a/TablePro/Core/Database/DocumentEditing.swift b/TablePro/Core/Database/DocumentEditing.swift index 8bebeabf63..5685f467d2 100644 --- a/TablePro/Core/Database/DocumentEditing.swift +++ b/TablePro/Core/Database/DocumentEditing.swift @@ -2,32 +2,41 @@ // DocumentEditing.swift // TablePro // -// Writes a whole document, for engines that store documents. +// Reads and writes a whole document, for engines that store documents. // import Foundation import TableProPluginKit -/// What an Insert Document sheet was opened for. +/// What a document sheet was opened for. /// /// The collection and its database travel with the request, because the object browser may point -/// somewhere else by the time the user presses Insert, and the document belongs to the collection the -/// sheet was opened on. +/// somewhere else by the time the user saves, and the document belongs to the collection the sheet +/// was opened on. An edit carries the row's locator, taken when the command was chosen, so a reload +/// that lands before the sheet opens cannot point it at another row's document. struct DocumentEditorRequest: Identifiable, Equatable { + enum Kind: Equatable { + case insert + case edit(locator: String) + } + let id: UUID let table: String let scope: DatabaseScope + let kind: Kind - init(id: UUID = UUID(), table: String, scope: DatabaseScope) { + init(id: UUID = UUID(), table: String, scope: DatabaseScope, kind: Kind = .insert) { self.id = id self.table = table self.scope = scope + self.kind = kind } } enum DocumentEditingError: LocalizedError, Equatable { case notConnected case denied(String) + case documentNotLoaded var errorDescription: String? { switch self { @@ -35,14 +44,30 @@ enum DocumentEditingError: LocalizedError, Equatable { String(localized: "Not connected to database") case .denied(let reason): reason + case .documentNotLoaded: + String(localized: "The document has not loaded, so there is nothing to save it over.") } } } @MainActor enum DocumentEditing { - static func insert( + /// The stored document an edit starts from, or nil when it no longer exists. An insert starts + /// from nothing. + static func load(_ request: DocumentEditorRequest) async throws -> String? { + guard case .edit(let locator) = request.kind else { return nil } + return try await DatabaseManager.shared.fetchDocument( + locator: locator, + table: request.table, + scope: request.scope + ) + } + + /// Writes `text` as a new document, or over the stored one `original` was read as. Returns + /// without writing when an edit changes nothing. + static func save( _ text: String, + original: String?, for request: DocumentEditorRequest, databaseType: DatabaseType, gate: any ExecutionGate = ExecutionGateProvider.shared @@ -50,20 +75,54 @@ enum DocumentEditing { let write = PluginDocumentWrite( table: request.table, schema: request.scope.schema, - operation: .insert(document: text) + operation: try operation(for: request.kind, text: text, original: original) ) guard let driver = DatabaseManager.shared.driver(for: request.scope.connectionId) else { throw DocumentEditingError.notConnected } - guard let statement = try driver.documentWriteStatement(write) else { return } + guard let statement = try await statement(for: write, on: driver) else { return } try await DatabaseManager.shared.executeDocumentWrite( write, statement: statement, databaseType: databaseType, scope: request.scope, - operationDescription: String(localized: "Insert Document"), + operationDescription: operationDescription(for: request.kind), gate: gate ) } + + /// An edit is never turned into an insert: without the text it was read as, there is no + /// document to replace, and writing a new one would duplicate it. + static func operation( + for kind: DocumentEditorRequest.Kind, + text: String, + original: String? + ) throws -> PluginDocumentWrite.Operation { + switch kind { + case .insert: + return .insert(document: text) + case .edit: + guard let original else { throw DocumentEditingError.documentNotLoaded } + return .replace(original: original, edited: text) + } + } + + static func operationDescription(for kind: DocumentEditorRequest.Kind) -> String { + switch kind { + case .insert: + String(localized: "Insert Document") + case .edit: + String(localized: "Edit Document") + } + } + + /// Off the main actor, because the driver reads and compares the whole document to build it. + @concurrent + nonisolated private static func statement( + for write: PluginDocumentWrite, + on driver: DatabaseDriver + ) async throws -> String? { + try driver.documentWriteStatement(write) + } } diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index db3f35da5f..5d9be8d56b 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -77,6 +77,10 @@ enum EditMenuBuilder { shortcut: .duplicateRow, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Edit Document…"), + action: #selector(MainSplitViewController.editDocument(_:)) + ), MenuItemFactory.item( String(localized: "Insert Document…"), action: #selector(MainSplitViewController.insertDocument(_:)) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index a5e2bfe871..216a7948d2 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -565,6 +565,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.executeDocumentWrite(write) } + func fetchDocument(table: String, schema: String?, locator: String) async throws -> String? { + try await pluginDriver.fetchDocument(table: table, schema: schema, locator: locator) + } + func createSchemaStatements(_ definition: PluginSchemaDefinition) -> [String]? { pluginDriver.createSchemaStatements(definition) } @@ -955,6 +959,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor result.columnMeta = pluginResult.columnMeta?.map { ResultColumnMeta(isPrimaryKey: $0.isPrimaryKey, isNullable: $0.isNullable, isAutoIncrement: $0.isIdentity) } + result.rowLocators = pluginResult.rowLocators return result } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift index b8fbdcfd8d..a1478d2b93 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift @@ -109,6 +109,10 @@ extension MainSplitViewController { commandActions?.duplicateRow() } + @objc func editDocument(_ sender: Any?) { + commandActions?.editDocument() + } + @objc func insertDocument(_ sender: Any?) { commandActions?.insertDocument() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 1010854e41..8e23dc6836 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -56,6 +56,8 @@ struct MenuValidationContext: Equatable { /// Insert Document writes a whole document, which only an engine that stores documents offers, /// on a collection tab with no staged grid edits. var canInsertDocument = false + /// Edit Document also needs exactly one selected row that the driver can find its document by. + var canEditDocument = false var isQueryExecuting = false /// Whether Stop still has something to act on. A batch whose `COMMIT` is on the wire is /// executing and unstoppable at the same time, and `Cmd+.` must dim rather than fire into it. @@ -262,6 +264,8 @@ extension MainSplitViewController: NSMenuItemValidation { && context.isCurrentTabSchemaResolved case #selector(restorePreviousValues(_:)): return context.isConnected && context.canRestorePreviousValues && !context.isReadOnly + case #selector(editDocument(_:)): + return context.isConnected && context.canEditDocument && !context.isReadOnly case #selector(insertDocument(_:)): return context.isConnected && context.canInsertDocument && !context.isReadOnly case #selector(truncateTable(_:)): @@ -435,11 +439,14 @@ extension MainSplitViewController: NSMenuItemValidation { /// The commands that act on the browse content, which Agent mode does not mount. /// - /// Every one of them has a toolbar twin whose `ToolbarContextResolver` arm answers no in Agent - /// mode, and the menu bar is where most of them now live, so leaving them lit here would be the - /// same defect one surface deeper: Refresh over a grid that is not there, Save over a commit gate - /// frozen at the moment the mode changed, Command Y flipping a persisted flag for a drawer that - /// is not mounted, and New Tab opening a tab behind the conversation. + /// Every one of them but the two document commands has a toolbar twin whose + /// `ToolbarContextResolver` arm answers no in Agent mode, and the menu bar is where most of them + /// now live, so leaving them lit here would be the same defect one surface deeper: Refresh over a + /// grid that is not there, Save over a commit gate frozen at the moment the mode changed, Command Y + /// flipping a persisted flag for a drawer that is not mounted, and New Tab opening a tab behind + /// the conversation. The document commands live on the Edit menu and on the grid's row menu, and + /// the row menu goes away with the grid, so the Edit menu is their only route in Agent mode, where + /// the coordinator still holds the row the user last selected. /// /// A set rather than an arm each, because the rule is one rule. `MenuContentModeParityTests` /// holds the two surfaces' answers together and derives this list back out of the toolbar, so a @@ -453,6 +460,8 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(saveDocument(_:)), #selector(addRow(_:)), #selector(restorePreviousValues(_:)), + #selector(editDocument(_:)), + #selector(insertDocument(_:)), #selector(previewSQL(_:)), #selector(toggleResults(_:)), #selector(toggleQueryHistory(_:)), @@ -598,6 +607,7 @@ extension MainSplitViewController: NSMenuItemValidation { isCurrentTabSchemaResolved: actions.isCurrentTabSchemaResolved, canRestorePreviousValues: actions.canRestorePreviousValues, canInsertDocument: actions.canInsertDocument, + canEditDocument: actions.canEditDocument, isQueryExecuting: actions.isQueryExecuting, isQueryStoppable: actions.isQueryStoppable, hasQueryText: actions.hasQueryText, diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 3b2208cc11..5875c58a12 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -20,6 +20,9 @@ struct QueryFetchResult { /// What the statement printed on the server, read on its own session. var serverOutput: PluginServerOutput = .none + /// Per row, what the driver finds that row by again. + var rowLocators: [String?]? + var resolvedTiming: PluginQueryTiming { timing ?? PluginQueryTiming(total: executionTime) } @@ -154,7 +157,8 @@ final class QueryExecutor { statusMessage: result.statusMessage, isTruncated: result.isTruncated, resultColumnMeta: result.columnMeta, - timing: result.timing + timing: result.timing, + rowLocators: result.rowLocators ) } @@ -180,7 +184,8 @@ final class QueryExecutor { statusMessage: result.statusMessage, isTruncated: result.isTruncated, resultColumnMeta: result.columnMeta, - timing: result.timing + timing: result.timing, + rowLocators: result.rowLocators ) } @@ -204,7 +209,8 @@ final class QueryExecutor { statusMessage: result.statusMessage, isTruncated: result.isTruncated, resultColumnMeta: result.columnMeta, - timing: result.timing + timing: result.timing, + rowLocators: result.rowLocators ) } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 3c6c38b016..1cb1854df4 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -32,6 +32,9 @@ struct QueryResult { var columnMeta: [ResultColumnMeta]? + /// Per row, what the driver finds that row by again. Only the driver reads it. + var rowLocators: [String?]? + /// What the statement printed on the server, read on its own session. var serverOutput: PluginServerOutput = .none diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 67472804cb..5c4e4dd1ff 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -28,6 +28,10 @@ struct TableRows: Sendable { /// empty set as "this table owns nothing". var hasAuthoritativeSchema: Bool var foreignKeysFetched: Bool + /// What the driver finds each fetched row by again, keyed by row identity so a sort, a value + /// filter or a removal cannot pair a row with another row's document. Only a driver that edits + /// whole documents fills it, and a row with none cannot be edited that way. + private(set) var rowLocators: [RowID: String] = [:] init( rows: ContiguousArray = [], @@ -92,10 +96,15 @@ struct TableRows: Sendable { return rows[index] } + func rowLocator(for id: RowID) -> String? { + rowLocators[id] + } + /// Releases the row payload while keeping the schema needed to render and reload the table. mutating func discardRowsKeepingMetadata() { rows = [] indexByID = [:] + rowLocators = [:] } @discardableResult @@ -151,7 +160,11 @@ struct TableRows: Sendable { } @discardableResult - mutating func appendPage(_ pageRows: [[PluginCellValue]], startingAt offset: Int) -> Delta { + mutating func appendPage( + _ pageRows: [[PluginCellValue]], + startingAt offset: Int, + rowLocators pageLocators: [String?]? = nil + ) -> Delta { guard !pageRows.isEmpty else { return .none } let firstIndex = rows.count rows.reserveCapacity(rows.count + pageRows.count) @@ -163,6 +176,7 @@ struct TableRows: Sendable { rows.append(row) indexByID[row.id] = newIndex } + rowLocators.merge(Self.locators(pageLocators, rowCount: pageRows.count, offset: offset)) { $1 } return .rowsInserted(IndexSet(integersIn: firstIndex...(rows.count - 1))) } @@ -195,7 +209,11 @@ struct TableRows: Sendable { } @discardableResult - mutating func replace(rows replacementRows: [[PluginCellValue]], offset: Int = 0) -> Delta { + mutating func replace( + rows replacementRows: [[PluginCellValue]], + offset: Int = 0, + rowLocators replacementLocators: [String?]? = nil + ) -> Delta { var rebuilt = ContiguousArray() rebuilt.reserveCapacity(replacementRows.count) var rebuiltIndex = [RowID: Int]() @@ -208,6 +226,7 @@ struct TableRows: Sendable { } rows = rebuilt indexByID = rebuiltIndex + rowLocators = Self.locators(replacementLocators, rowCount: replacementRows.count, offset: offset) return .fullReplace } @@ -284,7 +303,8 @@ struct TableRows: Sendable { generatedColumns: Set = [], rowMatchPolicy: RowMatchPolicy = .none, hasAuthoritativeSchema: Bool = false, - foreignKeysFetched: Bool = false + foreignKeysFetched: Bool = false, + rowLocators: [String?]? = nil ) -> TableRows { var rows = ContiguousArray() rows.reserveCapacity(queryRows.count) @@ -292,7 +312,7 @@ struct TableRows: Sendable { let normalized = normalize(values: values, toCount: columns.count) rows.append(Row(id: .existing(index), values: normalized)) } - return TableRows( + var tableRows = TableRows( rows: rows, columns: columns, columnTypes: columnTypes, @@ -307,6 +327,19 @@ struct TableRows: Sendable { hasAuthoritativeSchema: hasAuthoritativeSchema, foreignKeysFetched: foreignKeysFetched ) + tableRows.rowLocators = locators(rowLocators, rowCount: queryRows.count, offset: 0) + return tableRows + } + + /// Locators that do not pair one to one with the rows are dropped rather than guessed at. + private static func locators(_ locators: [String?]?, rowCount: Int, offset: Int) -> [RowID: String] { + guard let locators, locators.count == rowCount else { return [:] } + var keyed: [RowID: String] = [:] + for (index, locator) in locators.enumerated() { + guard let locator else { continue } + keyed[.existing(offset + index)] = locator + } + return keyed } private mutating func removeIndices(_ indices: IndexSet) -> Delta { @@ -315,6 +348,7 @@ struct TableRows: Sendable { let removedID = rows[index].id rows.remove(at: index) indexByID.removeValue(forKey: removedID) + rowLocators.removeValue(forKey: removedID) } if let minRemoved = indices.min(), minRemoved < rows.count { for offset in minRemoved.. [NSMenuItem] { guard let coordinator, coordinator.canInsertDocument else { return [] } - return [Self.menuItem(String(localized: "Insert Document…")) { [weak coordinator] in + var items: [NSMenuItem] = [] + if let locator = coordinator.documentLocator(forDisplayRow: displayRow) { + items.append(Self.menuItem(String(localized: "Edit Document…")) { [weak coordinator] in + coordinator?.presentEditDocument(locator: locator) + }) + } + items.append(Self.menuItem(String(localized: "Insert Document…")) { [weak coordinator] in coordinator?.presentInsertDocument() - }] + }) + return items } private static func menuItem(_ title: String, action: @escaping () -> Void) -> NSMenuItem { diff --git a/TablePro/Views/Main/Child/DocumentEditorPresentation.swift b/TablePro/Views/Main/Child/DocumentEditorPresentation.swift new file mode 100644 index 0000000000..d1c0bb2075 --- /dev/null +++ b/TablePro/Views/Main/Child/DocumentEditorPresentation.swift @@ -0,0 +1,94 @@ +// +// DocumentEditorPresentation.swift +// TablePro +// + +import Foundation + +/// What the document sheet shows and allows at each step. +/// +/// A write runs under a lease no Stop can interrupt, so while it is on its way the text stays read +/// only and the sheet cannot be dismissed: closing it would not stop the write, only hide its +/// answer. An edit starts by reading the stored document, and until that arrives there is nothing +/// to edit. +struct DocumentEditorPresentation: Equatable { + enum Phase: Equatable { + case loading + case loadFailed(String) + case missing + case editing + case saving + } + + let isEdit: Bool + let phase: Phase + + init(kind: DocumentEditorRequest.Kind, phase: Phase) { + if case .edit = kind { + isEdit = true + } else { + isEdit = false + } + self.phase = phase + } + + static func initialPhase(for kind: DocumentEditorRequest.Kind) -> Phase { + guard case .edit = kind else { return .editing } + return .loading + } + + var title: String { + isEdit ? String(localized: "Edit Document") : String(localized: "Insert Document") + } + + var saveTitle: String { + isEdit ? String(localized: "Save") : String(localized: "Insert") + } + + var showsEditor: Bool { + phase == .editing || phase == .saving + } + + var isEditable: Bool { + phase == .editing + } + + var canSave: Bool { + phase == .editing + } + + var showsSave: Bool { + showsEditor + } + + var canCancel: Bool { + phase != .saving + } + + var cancelTitle: String { + showsEditor || phase == .loading ? String(localized: "Cancel") : String(localized: "Close") + } + + var dismissDisabled: Bool { + phase == .saving + } + + /// Why there is no editor, in place of one. + var message: String? { + switch phase { + case .loadFailed(let reason): + return reason + case .missing: + return String(localized: "This document no longer exists. It was deleted after the grid loaded.") + case .loading, .editing, .saving: + return nil + } + } + + var hint: String { + guard isEdit else { + return String(localized: "Quote every field name. An ObjectId is {\"$oid\": \"…\"} and a date is {\"$date\": \"…\"}.") + } + return String(localized: "Save replaces the whole document with this text. A field you remove is removed, and _id cannot change.") + } +} diff --git a/TablePro/Views/Main/Child/DocumentEditorSheet.swift b/TablePro/Views/Main/Child/DocumentEditorSheet.swift index 6c7c796d30..8658571664 100644 --- a/TablePro/Views/Main/Child/DocumentEditorSheet.swift +++ b/TablePro/Views/Main/Child/DocumentEditorSheet.swift @@ -5,41 +5,50 @@ import SwiftUI -/// Inserts a document written as Extended JSON. +/// Inserts a document written as Extended JSON, or edits a stored one as that text. /// /// A grid can only write into the fields its sampled documents already have, so a collection with -/// no documents has nowhere to type the first field. The whole document is the unit a document -/// store writes, so a new document's fields are typed into the document itself. +/// no documents has nowhere to type the first field, and a field cannot be added, renamed or removed +/// from a cell. The whole document is the unit a document store writes, so it is edited whole. struct DocumentEditorSheet: View { - private enum Phase: Equatable { - case editing - case saving - } - @Environment(\.dismiss) private var dismiss let request: DocumentEditorRequest let databaseType: DatabaseType - @State private var text = "{\n \n}" - @State private var phase = Phase.editing + @State private var text: String + @State private var original: String? + @State private var phase: DocumentEditorPresentation.Phase @State private var saveError: String? + init(request: DocumentEditorRequest, databaseType: DatabaseType) { + self.request = request + self.databaseType = databaseType + _text = State(initialValue: request.kind == .insert ? "{\n \n}" : "") + _phase = State(initialValue: DocumentEditorPresentation.initialPhase(for: request.kind)) + } + + private var presentation: DocumentEditorPresentation { + DocumentEditorPresentation(kind: request.kind, phase: phase) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() - editor + content .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) Divider() buttonBar } .frame(minWidth: 560, idealWidth: 640, minHeight: 420, idealHeight: 560) + .interactiveDismissDisabled(presentation.dismissDisabled) + .task { await load() } } private var header: some View { VStack(alignment: .leading, spacing: 2) { - Text("Insert Document") + Text(presentation.title) .font(.headline) Text(request.table) .font(.subheadline) @@ -52,9 +61,28 @@ struct DocumentEditorSheet: View { .padding(.vertical, 14) } + @ViewBuilder + private var content: some View { + if presentation.showsEditor { + editor + } else if let message = presentation.message { + Text(message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(20) + .accessibilityIdentifier("document-editor-message") + } else { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("document-editor-loading") + } + } + private var editor: some View { VStack(alignment: .leading, spacing: 8) { - JSONCodeEditor(text: $text, isEditable: phase == .editing, accessibilityIdentifier: "document-editor") + JSONCodeEditor(text: $text, isEditable: presentation.isEditable, accessibilityIdentifier: "document-editor") .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color(nsColor: .separatorColor)) @@ -67,7 +95,7 @@ struct DocumentEditorSheet: View { .textSelection(.enabled) .accessibilityIdentifier("document-editor-error") } else { - Text("Quote every field name. An ObjectId is {\"$oid\": \"…\"} and a date is {\"$date\": \"…\"}.") + Text(presentation.hint) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -83,26 +111,45 @@ struct DocumentEditorSheet: View { .controlSize(.small) } Spacer() - Button(String(localized: "Cancel")) { + Button(presentation.cancelTitle) { dismiss() } .keyboardShortcut(.cancelAction) - Button(String(localized: "Insert")) { - Task { await save() } + .disabled(!presentation.canCancel) + if presentation.showsSave { + Button(presentation.saveTitle) { + Task { await save() } + } + .keyboardShortcut(.defaultAction) + .disabled(!presentation.canSave) + .accessibilityIdentifier("document-editor-submit") } - .keyboardShortcut(.defaultAction) - .disabled(phase != .editing) - .accessibilityIdentifier("document-editor-submit") } .padding(.horizontal, 20) .padding(.vertical, 14) } + private func load() async { + guard phase == .loading else { return } + do { + guard let stored = try await DocumentEditing.load(request) else { + phase = .missing + return + } + original = stored + text = stored + phase = .editing + } catch { + guard !Task.isCancelled else { return } + phase = .loadFailed(error.localizedDescription) + } + } + private func save() async { phase = .saving saveError = nil do { - try await DocumentEditing.insert(text, for: request, databaseType: databaseType) + try await DocumentEditing.save(text, original: original, for: request, databaseType: databaseType) dismiss() } catch { saveError = error.localizedDescription diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift index b9326e8bde..0a1ec6c5ed 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift @@ -8,12 +8,12 @@ import Foundation extension MainContentCoordinator { /// Whether the selected tab is a collection whose engine writes whole documents. /// - /// One definition for every place that offers Insert Document: the Edit menu, the row menu and - /// the empty-space menu. Only a table tab, because the write ends by reloading the tab and a - /// query tab's result is not reloaded that way, so the new document would not appear. A tab with - /// staged grid edits refuses it, because a reload over staged edits asks to discard them in a - /// sheet behind this one. - var canInsertDocument: Bool { + /// One definition for every place that offers Insert Document or Edit Document: the Edit menu, + /// the row menu and the empty-space menu. Only a table tab, because the write ends by reloading + /// the tab and a query tab's result is not reloaded that way, so the change would not appear. A + /// tab with staged grid edits refuses it, because a reload over staged edits asks to discard them + /// in a sheet behind this one. + var documentEditingAvailable: Bool { guard canEditActiveResult, PluginManager.shared.supportsDocumentEditing(for: connection.type), let tab = tabManager.selectedTab, @@ -23,10 +23,41 @@ extension MainContentCoordinator { return !changeManager.hasChanges } + var canInsertDocument: Bool { + documentEditingAvailable + } + func presentInsertDocument() { guard canInsertDocument, let table = tabManager.selectedTab?.tableContext.tableName, let scope = selectedTabScope else { return } activeSheet = .documentEditor(DocumentEditorRequest(table: table, scope: scope)) } + + /// What the driver finds the document on a grid row by, read through the display order so a + /// sort or a value filter cannot hand back another row's document. Nil for a row the driver + /// gave none, which is a row Edit Document is not offered on. + func documentLocator(forDisplayRow displayRow: Int) -> String? { + guard documentEditingAvailable, let tab = tabManager.selectedTab else { return nil } + let tableRows = tabSessionRegistry.tableRows(for: tab.id) + guard let row = DisplayRowMapping.row( + forDisplay: displayRow, + displayIDs: activeGridDisplayIDs, + in: tableRows + ) else { return nil } + return tableRows.rowLocator(for: row.id) + } + + func canEditDocument(atDisplayRow displayRow: Int) -> Bool { + documentLocator(forDisplayRow: displayRow) != nil + } + + /// Opens the editor on the document a locator names. The locator is resolved by the caller at + /// the moment the command was chosen, so a reload in between cannot move it to another row. + func presentEditDocument(locator: String) { + guard documentEditingAvailable, + let table = tabManager.selectedTab?.tableContext.tableName, + let scope = selectedTabScope else { return } + activeSheet = .documentEditor(DocumentEditorRequest(table: table, scope: scope, kind: .edit(locator: locator))) + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 54e5115442..5eb14043da 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -129,7 +129,8 @@ extension MainContentCoordinator { anchor: StatementAnchor? = nil, timing: PluginQueryTiming? = nil, viewport: GridReloadIntent = .firstRow, - serverOutput: PluginServerOutput = .none + serverOutput: PluginServerOutput = .none, + rowLocators: [String?]? = nil ) { queryExecutionCoordinator.applyPhase1Result( tabId: tabId, @@ -150,7 +151,8 @@ extension MainContentCoordinator { anchor: anchor, timing: timing, viewport: viewport, - serverOutput: serverOutput + serverOutput: serverOutput, + rowLocators: rowLocators ) } diff --git a/TablePro/Views/Main/MainContentCommandActions+Documents.swift b/TablePro/Views/Main/MainContentCommandActions+Documents.swift index 96a9fa2b45..63343c5163 100644 --- a/TablePro/Views/Main/MainContentCommandActions+Documents.swift +++ b/TablePro/Views/Main/MainContentCommandActions+Documents.swift @@ -13,4 +13,15 @@ extension MainContentCommandActions { var canInsertDocument: Bool { coordinator?.canInsertDocument ?? false } + + func editDocument() { + guard let displayRow = singleSelectedDataGridRow, + let locator = coordinator?.documentLocator(forDisplayRow: displayRow) else { return } + coordinator?.presentEditDocument(locator: locator) + } + + var canEditDocument: Bool { + guard let displayRow = singleSelectedDataGridRow else { return false } + return coordinator?.canEditDocument(atDisplayRow: displayRow) ?? false + } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2f14a10231..4e30706b3a 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -225,6 +225,15 @@ final class MainContentCommandActions: ObservableObject { private var dataGridOwnsSelection: Bool { selectionOwner == .dataGrid } + /// The display position of the one data-grid row a single-row command acts on, or nil when the + /// data grid does not own the selection or it holds other than one row. + var singleSelectedDataGridRow: Int? { + guard dataGridOwnsSelection else { return nil } + let indices = resolvedRowSelection() + guard indices.count == 1 else { return nil } + return indices.first + } + func deleteSelectedRows(rowIndices: Set? = nil) { let fromDataGrid = rowIndices != nil diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1bd5bd6ace..5fe6466e90 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1406,7 +1406,8 @@ final class MainContentCoordinator: ObservableObject { anchor: anchor, timing: fetchResult.resolvedTiming, viewport: viewport, - serverOutput: fetchResult.serverOutput + serverOutput: fetchResult.serverOutput, + rowLocators: isTableTab ? fetchResult.rowLocators : nil ) scheduleTraceCompletion(traceToken, outcome: .completed) diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 637028487d..7c09c4cc58 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -65,6 +65,16 @@ struct MainMenuStructureTests { #expect(database ?? 0 < window ?? 0) } + @Test("Edit Document sits just above Insert Document in the Edit menu") + func documentCommandsOrder() throws { + let edit = try #require(buildMenu().items.first { $0.title == String(localized: "Edit") }?.submenu) + let titles = edit.items.map(\.title) + let editIndex = try #require(titles.firstIndex(of: String(localized: "Edit Document…"))) + let insertIndex = try #require(titles.firstIndex(of: String(localized: "Insert Document…"))) + #expect(editIndex + 1 == insertIndex) + #expect(edit.items[editIndex].action == #selector(MainSplitViewController.editDocument(_:))) + } + @Test("No two menu items share a title") func titlesAreUnique() { let titles = flatten(buildMenu()) @@ -686,6 +696,7 @@ struct MainMenuValidationTests { context.hasMaintenanceOperations = true context.canCreateTable = true context.canInsertDocument = true + context.canEditDocument = true return context } @@ -724,6 +735,7 @@ struct MainMenuValidationTests { [ #selector(MainSplitViewController.addRow(_:)), #selector(MainSplitViewController.duplicateRow(_:)), + #selector(MainSplitViewController.editDocument(_:)), #selector(MainSplitViewController.insertDocument(_:)), #selector(MainSplitViewController.truncateTable(_:)), #selector(MainSplitViewController.delete(_:)), @@ -764,6 +776,37 @@ struct MainMenuValidationTests { #expect(!enabled(#selector(MainSplitViewController.insertDocument(_:)), context)) } + @Test("Edit Document needs one row whose document the driver can find, and stays dimmed on a read-only connection") + func editDocumentGate() { + var context = capableContext() + context.isConnected = true + #expect(enabled(#selector(MainSplitViewController.editDocument(_:)), context)) + context.canEditDocument = false + #expect(!enabled(#selector(MainSplitViewController.editDocument(_:)), context)) + context.canEditDocument = true + context.isReadOnly = true + #expect(!enabled(#selector(MainSplitViewController.editDocument(_:)), context)) + } + + /// Agent mode keeps the coordinator, and with it the row and the tab the user last had, so + /// only the mode can dim these. + @Test("Agent mode dims Edit Document and Insert Document over the row the coordinator still holds") + func documentCommandsDimInAgentMode() { + var context = capableContext() + context.isConnected = true + let documentCommands = [ + #selector(MainSplitViewController.editDocument(_:)), + #selector(MainSplitViewController.insertDocument(_:)) + ] + for selector in documentCommands { + #expect(enabled(selector, context), "\(selector) is dim while browsing") + } + context.isAgentMode = true + for selector in documentCommands { + #expect(!enabled(selector, context), "\(selector) stayed lit in Agent mode") + } + } + @Test("A stale selection does not keep content commands enabled without a connection") func contentCommandsNeedTheConnection() { var context = capableContext() diff --git a/TableProTests/Core/Plugins/PluginRowLocatorTests.swift b/TableProTests/Core/Plugins/PluginRowLocatorTests.swift new file mode 100644 index 0000000000..440bc84dbf --- /dev/null +++ b/TableProTests/Core/Plugins/PluginRowLocatorTests.swift @@ -0,0 +1,91 @@ +// +// PluginRowLocatorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class LocatingDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + var result = PluginQueryResult( + columns: ["_id"], + columnTypeNames: ["Int32"], + rows: [["1"], ["2"]], + rowsAffected: 0, + timing: PluginQueryTiming(total: 0) + ) + result.rowLocators = [#"{"$numberInt":"1"}"#, nil] + return result + } + + 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) + } +} + +struct PluginRowLocatorTests { + private let locators: [String?] = [#"{"$numberInt":"1"}"#, nil] + + private func makeAdapter() -> PluginDriverAdapter { + PluginDriverAdapter(connection: DatabaseConnection(name: "Test", type: .mongodb), pluginDriver: LocatingDriver()) + } + + @Test("A driver's row locators reach the query result and the fetch result unchanged") + func locatorsFlowToTheFetchResult() async throws { + let adapter = makeAdapter() + let result = try await adapter.executeUserQuery(query: "db.c.find({})", rowCap: nil, parameters: nil) + #expect(result.rowLocators == locators) + let fetched = try await QueryExecutor.fetchQueryData(driver: adapter, sql: "db.c.find({})", rowCap: nil) + #expect(fetched.rowLocators == locators) + } + + @Test("A driver built before documents could be fetched refuses rather than answering") + func defaultFetchDocumentRefuses() async { + await #expect(throws: PluginDriverUnsupportedOperation.writeDocument) { + try await makeAdapter().fetchDocument(table: "c", schema: nil, locator: "1") + } + } + + @Test("Row locators survive the result's own coding, and a payload without them decodes") + func coding() throws { + var result = PluginQueryResult( + columns: ["_id"], columnTypeNames: ["Int32"], rows: [["1"], ["2"]], rowsAffected: 0, + timing: PluginQueryTiming(total: 0) + ) + result.rowLocators = locators + let decoded = try JSONDecoder().decode(PluginQueryResult.self, from: JSONEncoder().encode(result)) + #expect(decoded.rowLocators == locators) + + var legacy = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(PluginQueryResult.empty)) as? [String: Any] + ) + legacy.removeValue(forKey: "rowLocators") + let decodedLegacy = try JSONDecoder().decode( + PluginQueryResult.self, + from: JSONSerialization.data(withJSONObject: legacy) + ) + #expect(decodedLegacy.rowLocators == nil) + } +} diff --git a/TableProTests/Models/Query/TableRowsRowLocatorTests.swift b/TableProTests/Models/Query/TableRowsRowLocatorTests.swift new file mode 100644 index 0000000000..08b699f698 --- /dev/null +++ b/TableProTests/Models/Query/TableRowsRowLocatorTests.swift @@ -0,0 +1,79 @@ +// +// TableRowsRowLocatorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct TableRowsRowLocatorTests { + private func table(_ locators: [String?]?) -> TableRows { + TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["_id"], + columnTypes: [.text(rawType: nil)], + rowLocators: locators + ) + } + + @Test("Locators are keyed by row identity, and a row with none has none") + func keyedByIdentity() { + let rows = table(["A", nil, "C"]) + #expect(rows.rowLocator(for: .existing(0)) == "A") + #expect(rows.rowLocator(for: .existing(1)) == nil) + #expect(rows.rowLocator(for: .existing(2)) == "C") + } + + @Test("A sort on the host keeps each row's locator with its row") + func surviveReorder() { + var rows = table(["A", "B", "C"]) + rows.reorderRows(ContiguousArray(rows.rows.reversed())) + #expect(rows.rows[0].id == .existing(2)) + #expect(rows.rowLocator(for: rows.rows[0].id) == "C") + #expect(rows.rowLocator(for: rows.rows[2].id) == "A") + } + + @Test("Removing a row takes its locator and leaves the others") + func surviveRemoval() { + var rows = table(["A", "B", "C"]) + rows.remove(rowIDs: [.existing(0)]) + #expect(rows.rowLocator(for: .existing(0)) == nil) + #expect(rows.rowLocator(for: .existing(1)) == "B") + #expect(rows.rowLocator(for: rows.rows[1].id) == "C") + } + + @Test("Replacing the rows replaces the locators, keyed from the offset") + func replaceWithOffset() { + var rows = table(["A", "B", "C"]) + rows.replace(rows: [["x"], ["y"]], offset: 10, rowLocators: ["X", "Y"]) + #expect(rows.rowLocator(for: .existing(10)) == "X") + #expect(rows.rowLocator(for: .existing(11)) == "Y") + #expect(rows.rowLocator(for: .existing(0)) == nil) + rows.replace(rows: [["z"]]) + #expect(rows.rowLocator(for: .existing(0)) == nil) + } + + @Test("A page appended after the first keeps its own locators") + func appendPage() { + var rows = table(["A", "B", "C"]) + rows.appendPage([["d"]], startingAt: 3, rowLocators: ["D"]) + #expect(rows.rowLocator(for: .existing(3)) == "D") + #expect(rows.rowLocator(for: .existing(0)) == "A") + } + + @Test("Locators that do not pair one to one with the rows are not kept") + func countMismatch() { + let rows = table(["A"]) + #expect(rows.rowLocators.isEmpty) + #expect(table(nil).rowLocators.isEmpty) + } + + @Test("Releasing the rows releases their locators") + func discardClears() { + var rows = table(["A", "B", "C"]) + rows.discardRowsKeepingMetadata() + #expect(rows.rowLocators.isEmpty) + } +} diff --git a/TableProTests/Plugins/MongoDocumentGuardTests.swift b/TableProTests/Plugins/MongoDocumentGuardTests.swift new file mode 100644 index 0000000000..9e5fff92e2 --- /dev/null +++ b/TableProTests/Plugins/MongoDocumentGuardTests.swift @@ -0,0 +1,185 @@ +// +// MongoDocumentGuardTests.swift +// TableProTests +// +// What the server answers for these filters is checked against a live server by +// scripts/check-mongodb-document-guard.sh; these pin the text the driver builds. +// + +import Foundation +import Testing + +struct MongoDocumentGuardTests { + private func filter(_ canonical: String) throws -> String { + try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: canonical)) + } + + /// The `$literal` the type signature is compared with, which is the last one in the filter. + private func expectedSignature(_ canonical: String) throws -> String { + let text = try filter(canonical) + let marker = #"{"$literal":"# + let start = try #require(text.range(of: marker, options: .backwards)).upperBound + return String(text[start...].dropLast(6)) + } + + @Test("A flat document's filter, in full") + func goldenFilter() throws { + let children = #"{"$cond":[{"$isArray":"$$ROOT"},"$$ROOT",{"$map":{"input":{"$objectToArray":"# + + #"{"$cond":[{"$eq":[{"$type":"$$ROOT"},"object"]},"$$ROOT",{}]}},"in":"$$this.v"}}]}"# + let leaf = #"{"$cond":[{"$or":[{"$eq":[{"$type":"$$this"},"decimal"]},{"$and":[{"$eq":[{"$type":"$$this"},"# + + #""double"]},{"$eq":["$$this",0]}]}]},[{"$type":"$$this"},{"$convert":{"input":"$$this","to":"string","# + + #""onError":"","onNull":""}}],{"$type":"$$this"}]}"# + let signature = #"{"$map":{"input":\#(children),"in":\#(leaf)}}"# + let document = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"5"}}"# + let expected = #"{"_id":{"$numberInt":"1"},"$expr":{"$and":[{"$eq":["$$ROOT",{"$literal":\#(document)}]},"# + + #"{"$eq":[\#(signature),{"$literal":["int","int"]}]}]}}"# + #expect(try filter(#"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberInt" : "5" } }"#) == expected) + } + + @Test("The whole-document comparison comes first, so the signature is only asked about a document that matched") + func wholeDocumentFirst() throws { + let text = try filter(#"{"_id":{"$numberInt":"1"}}"#) + let root = try #require(text.range(of: #""$and":[{"$eq":["$$ROOT",{"$literal":"#)) + let map = try #require(text.range(of: #""$map""#)) + #expect(root.lowerBound < map.lowerBound) + } + + @Test("Every type marker is compared by its $type name") + func typeNames() throws { + let cases: [(String, String)] = [ + (#"{"$oid":"65f0a1b2c3d4e5f607182930"}"#, #""objectId""#), + (#"{"$numberInt":"5"}"#, #""int""#), + (#"{"$numberLong":"5"}"#, #""long""#), + (#"{"$numberDouble":"5.0"}"#, #""double""#), + (#"{"$date":{"$numberLong":"0"}}"#, #""date""#), + (#"{"$binary":{"base64":"AQID","subType":"00"}}"#, #""binData""#), + (#"{"$regularExpression":{"pattern":"a","options":""}}"#, #""regex""#), + (#"{"$timestamp":{"t":5,"i":1}}"#, #""timestamp""#), + (#"{"$minKey":1}"#, #""minKey""#), + (#"{"$maxKey":1}"#, #""maxKey""#), + (#"{"$symbol":"s"}"#, #""symbol""#), + (#"{"$undefined":true}"#, #""undefined""#), + (#"{"$dbPointer":{"$ref":"c","$id":{"$oid":"65f0a1b2c3d4e5f607182930"}}}"#, #""dbPointer""#), + (#"{"$code":"x"}"#, #""javascript""#), + (#""text""#, #""string""#), + ("true", #""bool""#), + ("null", #""null""#) + ] + for (value, type) in cases { + let signature = try expectedSignature(#"{"_id":{"$numberInt":"1"},"v":\#(value)}"#) + #expect(signature == #"["int",\#(type)]"#, "\(value)") + } + } + + @Test("A decimal carries its text and a zero double its sign, and nothing else does") + func textWhereEqualityIsLenient() throws { + let signature = try expectedSignature( + #"{"_id":{"$numberInt":"1"},"a":{"$numberDecimal":"1.00"},"b":{"$numberDouble":"-0.0"},"# + + #""c":{"$numberDouble":"0.0"},"d":{"$numberDouble":"2.5"},"e":{"$numberDecimal":"Infinity"}}"# + ) + #expect(signature == #"["int",["decimal","1.00"],["double","-0"],["double","0"],"double",["decimal","Infinity"]]"#) + } + + /// The server's `$eq` answers yes for two NaNs whatever their bits, and `$type` and `$convert` + /// cannot tell them apart either, so no filter can notice one NaN replaced by another. + @Test("A NaN anywhere in the document is refused, double or decimal, at any depth") + func notANumberIsRefused() { + let documents = [ + #"{"_id":{"$numberInt":"1"},"n":{"$numberDouble":"NaN"}}"#, + #"{"_id":{"$numberInt":"1"},"n":{"$numberDecimal":"NaN"}}"#, + #"{"_id":{"$numberDouble":"NaN"}}"#, + #"{"_id":{"$numberInt":"1"},"o":{"a":[{"$numberInt":"1"},{"$numberDouble":"NaN"}]}}"# + ] + for document in documents { + #expect(throws: MongoDocumentGuard.Refusal.notANumber, "\(document)") { + try filter(document) + } + } + } + + /// A scope is compared the way a query compares, so `1` and `NumberLong(1)` inside it are equal, + /// and no expression can open it to compare its types instead. + @Test("JavaScript code with a scope is refused anywhere, even with an empty scope") + func codeWithScopeIsRefused() { + let documents = [ + #"{"_id":{"$numberInt":"1"},"c":{"$code":"x","$scope":{"a":{"$numberInt":"1"}}}}"#, + #"{"_id":{"$numberInt":"1"},"c":{"$code":"x","$scope":{}}}"#, + #"{"_id":{"$numberInt":"1"},"a":[{"o":{"$code":"x","$scope":{}}}]}"# + ] + for document in documents { + #expect(throws: MongoDocumentGuard.Refusal.codeWithScope, "\(document)") { + try filter(document) + } + } + } + + @Test("Code without a scope, and a field named NaN, are guarded as usual") + func nearMissesAreGuarded() throws { + let signature = try expectedSignature( + #"{"_id":{"$numberInt":"1"},"c":{"$code":"x"},"NaN":"NaN","d":{"$numberDouble":"Infinity"}}"# + ) + #expect(signature == #"["int","javascript","string","double"]"#) + } + + @Test("Above the last level each child pairs its own name with its children's signature") + func nestedSignature() throws { + let signature = try expectedSignature( + #"{"_id":{"$numberInt":"1"},"o":{"x":{"$numberLong":"2"}},"a":[{"$numberInt":"3"},"s"]}"# + ) + #expect(signature == #"[["int",[]],["object",["long"]],["array",["int","string"]]]"#) + } + + @Test("A container is read by position and never through a field path, whatever its field names") + func namesStayInsideTheLiteral() throws { + let document = #"{"_id":{"$numberInt":"1"},"a.b":{"$numberInt":"1"},"":"e","x":{"$w":{"$numberInt":"1"}}}"# + let text = try filter(document) + let outsideLiteral = text.replacingOccurrences(of: document, with: "") + #expect(!outsideLiteral.contains(#""a.b""#)) + #expect(!outsideLiteral.contains(#""$w""#)) + #expect(!outsideLiteral.contains(#""$a.b""#)) + } + + @Test("Every operator that could raise on what the field holds now is guarded") + func totality() throws { + let text = try filter(#"{"_id":{"$numberInt":"1"},"o":{"a":[{"b":{"$numberDecimal":"1"}}]}}"#) + #expect(!text.contains("$toString")) + #expect(!text.contains("$arrayElemAt")) + let objectToArray = text.components(separatedBy: #"{"$objectToArray":"#).dropFirst() + #expect(!objectToArray.isEmpty) + #expect(objectToArray.allSatisfy { $0.hasPrefix(#"{"$cond":[{"$eq":[{"$type":"#) }) + let conversions = text.components(separatedBy: #"{"$convert":"#).dropFirst() + #expect(conversions.allSatisfy { $0.hasPrefix(#"{"input":"$$this","to":"string","onError":"","onNull":""}"#) }) + } + + @Test("A document as deep as libbson can read the filter of builds, and one level more is refused") + func depthLimit() throws { + func nested(_ depth: Int) -> String { + var value = #"{"$numberInt":"1"}"# + for _ in 0 ..< depth - 1 { + value = #"{"k":\#(value)}"# + } + return #"{"_id":{"$numberInt":"1"},"v":\#(value)}"# + } + let deepest = try MongoDocumentText(parsing: nested(MongoDocumentGuard.maximumDepth)) + #expect(try MongoDocumentGuard.depth(of: .object(deepest.members)) == MongoDocumentGuard.maximumDepth) + _ = try MongoDocumentGuard.filter(for: deepest) + #expect(throws: MongoDocumentGuard.Refusal.tooDeep) { + try filter(nested(MongoDocumentGuard.maximumDepth + 1)) + } + } + + @Test("A type marker is one value, not a level of nesting") + func markersAreScalars() throws { + let document = try MongoDocumentText( + parsing: #"{"_id":{"$oid":"65f0a1b2c3d4e5f607182930"},"d":{"$date":{"$numberLong":"0"}}}"# + ) + #expect(try MongoDocumentGuard.depth(of: .object(document.members)) == 1) + } + + @Test("A document with no _id has nothing to guard") + func missingIdentity() { + #expect(throws: MongoDBDocumentEditingError.missingIdentity) { + try filter(#"{"a":{"$numberInt":"1"}}"#) + } + } +} diff --git a/TableProTests/Plugins/MongoDocumentIdentityTests.swift b/TableProTests/Plugins/MongoDocumentIdentityTests.swift new file mode 100644 index 0000000000..d4c719b378 --- /dev/null +++ b/TableProTests/Plugins/MongoDocumentIdentityTests.swift @@ -0,0 +1,99 @@ +// +// MongoDocumentIdentityTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoDocumentIdentityTests { + private let objectIdHex = "65f0a1b2c3d4e5f607182930" + + @Test("The locator is the compact canonical _id of libbson's spaced output") + func readsLibbsonShapedText() { + let cases: [(String, String)] = [ + (#"{ "$oid" : "65f0a1b2c3d4e5f607182930" }"#, #"{"$oid":"65f0a1b2c3d4e5f607182930"}"#), + (#""plain""#, #""plain""#), + (#"{ "$numberInt" : "1" }"#, #"{"$numberInt":"1"}"#), + (#"{ "$numberLong" : "1" }"#, #"{"$numberLong":"1"}"#), + (#"{ "$numberDouble" : "1.0" }"#, #"{"$numberDouble":"1.0"}"#), + (#"{ "$date" : { "$numberLong" : "1714557600000" } }"#, #"{"$date":{"$numberLong":"1714557600000"}}"#), + ("true", "true"), + (#"{ "b" : { "$numberInt" : "2" }, "a" : { "$numberInt" : "1" } }"#, #"{"b":{"$numberInt":"2"},"a":{"$numberInt":"1"}}"#), + (#"{ "$binary" : { "base64" : "AQID", "subType" : "00" } }"#, #"{"$binary":{"base64":"AQID","subType":"00"}}"#), + (#"{ "$binary" : { "base64" : "OyQRAeK7QlWMr0E2xWapYg==", "subType" : "04" } }"#, + #"{"$binary":{"base64":"OyQRAeK7QlWMr0E2xWapYg==","subType":"04"}}"#) + ] + for (stored, locator) in cases { + let document = #"{ "_id" : \#(stored), "n" : { "$numberInt" : "1" } }"# + #expect(MongoDocumentIdentity.locator(inDocument: document) == locator) + } + } + + @Test("An _id after other fields is found without being confused by braces inside strings") + func idNotFirst() { + let document = #"{ "a" : [ { "b" : "}]\"{" }, [ 1, 2 ] ], "c" : true, "_id" : "x" }"# + #expect(MongoDocumentIdentity.locator(inDocument: document) == #""x""#) + } + + @Test("A document with no _id has no locator") + func noIdentity() { + #expect(MongoDocumentIdentity.locator(inDocument: #"{ "a" : 1 }"#) == nil) + #expect(MongoDocumentIdentity.locator(inDocument: "{}") == nil) + #expect(MongoDocumentIdentity.locator(inDocument: "not json") == nil) + } + + @Test("An ObjectId and a string with the same hex are different documents") + func objectIdAndSameHexString() throws { + let objectId = try #require(MongoDocumentIdentity.locator( + inDocument: #"{ "_id" : { "$oid" : "\#(objectIdHex)" } }"# + )) + let string = try #require(MongoDocumentIdentity.locator(inDocument: #"{ "_id" : "\#(objectIdHex)" }"#)) + #expect(objectId != string) + let identity = try MongoDocumentIdentity(locator: objectId) + #expect(!identity.identifies(#"{ "_id" : "\#(objectIdHex)", "n" : 2 }"#)) + #expect(identity.identifies(#"{ "_id" : { "$oid" : "\#(objectIdHex)" }, "n" : 1 }"#)) + } + + @Test("A locator is read as one value, and anything more is refused") + func strictLocator() { + for text in [ + "1}); db.x.drop(); ({", + #"1, "a": 2"#, + #"1, "$where": "x""#, + #"{"$ne": null}"#, + #"{"$gt": ""}"#, + "", + #"1} {"_id": 2"# + ] { + #expect(throws: MongoDBDocumentEditingError.unknownDocument) { + try MongoDocumentIdentity(locator: text) + } + } + } + + @Test("A locator is kept compact, and its filter names only _id") + func compactFilter() throws { + let identity = try MongoDocumentIdentity(locator: #"{ "$oid" : "\#(objectIdHex)" }"#) + #expect(identity.locator == #"{"$oid":"\#(objectIdHex)"}"#) + #expect(identity.filter == #"{"_id":{"$oid":"\#(objectIdHex)"}}"#) + } + + @Test("A query's numeric and field-order leniency does not identify a document") + func noNumericLeniency() throws { + let int32 = try MongoDocumentIdentity(locator: #"{"$numberInt":"1"}"#) + #expect(!int32.identifies(#"{ "_id" : { "$numberDouble" : "1.0" } }"#)) + #expect(!int32.identifies(#"{ "_id" : { "$numberLong" : "1" } }"#)) + let document = try MongoDocumentIdentity(locator: #"{"a":{"$numberInt":"1"}}"#) + #expect(!document.identifies(#"{ "_id" : { "a" : { "$numberLong" : "1" } } }"#)) + let ordered = try MongoDocumentIdentity(locator: #"{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}}"#) + #expect(!ordered.identifies(#"{ "_id" : { "b" : { "$numberInt" : "2" }, "a" : { "$numberInt" : "1" } } }"#)) + } + + @Test("Two Unicode spellings of one string are two identities, as they are to the server") + func unicodeSpellings() throws { + let composed = try MongoDocumentIdentity(locator: "\"caf\u{E9}\"") + #expect(composed.identifies("{ \"_id\" : \"caf\u{E9}\" }")) + #expect(!composed.identifies("{ \"_id\" : \"cafe\u{301}\" }")) + } +} diff --git a/TableProTests/Plugins/MongoDocumentPresentationTests.swift b/TableProTests/Plugins/MongoDocumentPresentationTests.swift new file mode 100644 index 0000000000..9b55688306 --- /dev/null +++ b/TableProTests/Plugins/MongoDocumentPresentationTests.swift @@ -0,0 +1,116 @@ +// +// MongoDocumentPresentationTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoDocumentPresentationTests { + private func shown(_ canonicalValue: String) throws -> String { + let document = try MongoDocumentText(parsing: #"{"v":\#(canonicalValue)}"#) + let value = try #require(document.value(of: "v")) + return MongoDocumentPresentation.readable(value).compactText + } + + @Test("A 32-bit integer reads as a bare number") + func int32() throws { + #expect(try shown(#"{"$numberInt":"42"}"#) == "42") + } + + @Test("A 64-bit integer inside the 32-bit range keeps its wrapper, since bare it would read back as 32-bit") + func smallInt64() throws { + #expect(try shown(#"{"$numberLong":"5"}"#) == #"{"$numberLong":"5"}"#) + #expect(try shown(#"{"$numberLong":"2147483647"}"#) == #"{"$numberLong":"2147483647"}"#) + } + + @Test("A 64-bit integer outside the 32-bit range reads as a bare number") + func largeInt64() throws { + #expect(try shown(#"{"$numberLong":"9007199254740993"}"#) == "9007199254740993") + #expect(try shown(#"{"$numberLong":"-2147483649"}"#) == "-2147483649") + } + + @Test("A double reads in its shortest exact form and always shows it is a double") + func doubles() throws { + #expect(try shown(#"{"$numberDouble":"0.10000000000000000555"}"#) == "0.1") + #expect(try shown(#"{"$numberDouble":"3.0"}"#) == "3.0") + #expect(try shown(#"{"$numberDouble":"-0.0"}"#) == "-0.0") + #expect(try shown(#"{"$numberDouble":"1e+300"}"#) == "1e+300") + } + + @Test("A double JSON cannot spell keeps its wrapper") + func specialDoubles() throws { + #expect(try shown(#"{"$numberDouble":"Infinity"}"#) == #"{"$numberDouble":"Infinity"}"#) + #expect(try shown(#"{"$numberDouble":"NaN"}"#) == #"{"$numberDouble":"NaN"}"#) + } + + @Test("A date from 1970 through 9999 reads as ISO text, keeping its milliseconds") + func relaxedDates() throws { + #expect(try shown(#"{"$date":{"$numberLong":"1714557600123"}}"#) == #"{"$date":"2024-05-01T10:00:00.123Z"}"#) + #expect(try shown(#"{"$date":{"$numberLong":"0"}}"#) == #"{"$date":"1970-01-01T00:00:00Z"}"#) + #expect(try shown(#"{"$date":{"$numberLong":"253402300799999"}}"#) == #"{"$date":"9999-12-31T23:59:59.999Z"}"#) + } + + @Test("A date outside the range libbson reads back as ISO keeps its canonical form") + func canonicalDates() throws { + #expect(try shown(#"{"$date":{"$numberLong":"-1000"}}"#) == #"{"$date":{"$numberLong":"-1000"}}"#) + #expect(try shown(#"{"$date":{"$numberLong":"253402300800000"}}"#) == #"{"$date":{"$numberLong":"253402300800000"}}"#) + } + + @Test("Other wrappers are left exactly as they are") + func otherWrappers() throws { + for wrapper in [ + #"{"$oid":"507f1f77bcf86cd799439011"}"#, + #"{"$numberDecimal":"1.10"}"#, + #"{"$binary":{"base64":"AAAA","subType":"04"}}"#, + #"{"$timestamp":{"t":5,"i":1}}"# + ] { + #expect(try shown(wrapper) == wrapper) + } + } + + @Test("Numbers inside arrays and subdocuments are made readable too") + func nested() throws { + #expect(try shown(#"[{"$numberInt":"1"},{"a":{"$numberInt":"2"}}]"#) == #"[1,{"a":2}]"#) + } + + @Test("The editable text is indented, keeps field order and puts a wrapper on one line") + func editableText() throws { + let document = try MongoDocumentText( + parsing: #"{"_id":{"$oid":"507f1f77bcf86cd799439011"},"n":{"$numberInt":"1"},"tags":[],"o":{"k":"v"}}"# + ) + #expect(MongoDocumentPresentation.editableText(document) == """ + { + "_id": {"$oid":"507f1f77bcf86cd799439011"}, + "n": 1, + "tags": [], + "o": { + "k": "v" + } + } + """) + } + + @Test("The canonical fallback keeps every wrapper and the same layout") + func prettyCanonical() throws { + let document = try MongoDocumentText( + parsing: #"{"_id":{"$numberInt":"1"},"n":{"$numberLong":"5"},"o":{"k":{"$numberDouble":"0.1"}}}"# + ) + #expect(MongoDocumentPresentation.prettyCanonical(document) == """ + { + "_id": {"$numberInt":"1"}, + "n": {"$numberLong":"5"}, + "o": { + "k": {"$numberDouble":"0.1"} + } + } + """) + } + + @Test("The editable text reads back as the same document") + func editableTextRereads() throws { + let document = try MongoDocumentText(parsing: #"{"a":[1,{"b":[]}],"s":"x\ny"}"#) + let reread = try MongoDocumentText(parsing: MongoDocumentPresentation.editableText(document)) + #expect(reread == document) + } +} diff --git a/TableProTests/Plugins/MongoDocumentReplacementTests.swift b/TableProTests/Plugins/MongoDocumentReplacementTests.swift new file mode 100644 index 0000000000..2a5cd5c4cd --- /dev/null +++ b/TableProTests/Plugins/MongoDocumentReplacementTests.swift @@ -0,0 +1,94 @@ +// +// MongoDocumentReplacementTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct MongoDocumentReplacementTests { + private func replacement(original: String, edited: String) throws -> MongoDocumentReplacement { + try MongoDocumentReplacement( + original: MongoDocumentText(parsing: original), + edited: MongoDocumentText(parsing: edited) + ) + } + + private let original = #"{"_id":{"$numberInt":"1"},"a":{"$numberInt":"1"},"b":"x"}"# + + @Test("The stored _id goes first, then the fields in the order they were written") + func idFirstThenWrittenOrder() throws { + let result = try replacement( + original: original, + edited: #"{"z":{"$numberInt":"1"},"_id":{"$numberInt":"1"},"a":{"$numberInt":"2"}}"# + ) + #expect(result.document.compactText == #"{"_id":{"$numberInt":"1"},"z":{"$numberInt":"1"},"a":{"$numberInt":"2"}}"#) + #expect(result.changesDocument) + } + + @Test("An _id the text leaves out is put back rather than dropped") + func omittedIdIsRestored() throws { + let result = try replacement(original: original, edited: #"{"a":{"$numberInt":"1"},"b":"x"}"#) + #expect(result.document.compactText == original) + #expect(!result.changesDocument) + } + + @Test("A changed _id is refused, a reordered subdocument _id included") + func changedIdIsRefused() { + #expect(throws: MongoDBDocumentEditingError.identityChanged) { + try replacement(original: original, edited: #"{"_id":{"$numberInt":"2"}}"#) + } + #expect(throws: MongoDBDocumentEditingError.identityChanged) { + try replacement(original: original, edited: #"{"_id":{"$numberLong":"1"}}"#) + } + #expect(throws: MongoDBDocumentEditingError.identityChanged) { + try replacement( + original: #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}}}"#, + edited: #"{"_id":{"b":{"$numberInt":"2"},"a":{"$numberInt":"1"}}}"# + ) + } + } + + @Test("An unchanged document changes nothing") + func unchanged() throws { + #expect(try !replacement(original: original, edited: original).changesDocument) + } + + @Test("Dotted, empty, integer-like and nested $ names are kept verbatim and in order") + func oddNames() throws { + let edited = #"{"_id":{"$numberInt":"1"},"2":"two","price.usd":{"$numberInt":"9"},"":"e","x":{"$w":{"$numberInt":"1"}}}"# + #expect(try replacement(original: original, edited: edited).document.compactText == edited) + } + + @Test("A top-level $ field is refused when the text is read") + func topLevelOperator() { + #expect(throws: MongoDocumentText.Refusal.operatorField("$p")) { + try replacement(original: original, edited: #"{"$p":{"$numberInt":"1"}}"#) + } + } + + @Test("A value respelled in another Unicode form is a change") + func unicodeRespelling() throws { + let composed = "{\"_id\":{\"$numberInt\":\"1\"},\"s\":\"caf\u{E9}\"}" + let decomposed = "{\"_id\":{\"$numberInt\":\"1\"},\"s\":\"cafe\u{301}\"}" + #expect(try replacement(original: composed, edited: decomposed).changesDocument) + } + + @Test("Timestamp(0, 0) in a top-level field is refused, and nested it is kept") + func emptyTimestamp() throws { + #expect(throws: MongoDBDocumentEditingError.emptyTimestamp("ts")) { + try replacement(original: original, edited: #"{"ts":{"$timestamp":{"t":0,"i":0}}}"#) + } + let nested = #"{"_id":{"$numberInt":"1"},"o":{"ts":{"$timestamp":{"t":0,"i":0}}}}"# + #expect(try replacement(original: original, edited: nested).document.compactText == nested) + let stamped = #"{"_id":{"$numberInt":"1"},"ts":{"$timestamp":{"t":5,"i":1}}}"# + #expect(try replacement(original: original, edited: stamped).changesDocument) + } + + @Test("A document with no _id cannot be replaced") + func missingIdentity() { + #expect(throws: MongoDBDocumentEditingError.missingIdentity) { + try replacement(original: #"{"a":{"$numberInt":"1"}}"#, edited: #"{"a":{"$numberInt":"2"}}"#) + } + } +} diff --git a/TableProTests/Plugins/MongoDocumentWritePlanTests.swift b/TableProTests/Plugins/MongoDocumentWritePlanTests.swift index ce20551717..b19845fd01 100644 --- a/TableProTests/Plugins/MongoDocumentWritePlanTests.swift +++ b/TableProTests/Plugins/MongoDocumentWritePlanTests.swift @@ -19,8 +19,8 @@ struct MongoDocumentWritePlanTests { operation: .insert(document: "{\n \"name\": \"launch\"\n}"), canonicalize: canonical ) - #expect(plan.document == #"{"name":"launch"}"#) - #expect(plan.statement == #"db.events.insertOne({"name":"launch"})"#) + #expect(plan?.write == .insert(document: #"{"name":"launch"}"#)) + #expect(plan?.statement == #"db.events.insertOne({"name":"launch"})"#) } @Test("An empty document is a valid insert") @@ -28,7 +28,7 @@ struct MongoDocumentWritePlanTests { let plan = try MongoDocumentWritePlan.make( collection: "events", operation: .insert(document: "{}"), canonicalize: canonical ) - #expect(plan.document == "{}") + #expect(plan?.write == .insert(document: "{}")) } @Test("Field order is kept, integer-like names included, which a JavaScript object would move") @@ -36,7 +36,7 @@ struct MongoDocumentWritePlanTests { let plan = try MongoDocumentWritePlan.make( collection: "events", operation: .insert(document: #"{"b":1,"2":2,"a":3}"#), canonicalize: canonical ) - #expect(plan.document == #"{"b":1,"2":2,"a":3}"#) + #expect(plan?.write == .insert(document: #"{"b":1,"2":2,"a":3}"#)) } @Test("Text libbson would misread is refused before it is asked") @@ -73,6 +73,57 @@ struct MongoDocumentWritePlanTests { operation: .insert(document: "{}"), canonicalize: canonical ) - #expect(plan.statement == #"db.getCollection("x\").drop(); db.getCollection(\"y").insertOne({})"#) + #expect(plan?.statement == #"db.getCollection("x\").drop(); db.getCollection(\"y").insertOne({})"#) + } + + private let stored = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"5"}}"# + + @Test("An edit replaces the whole document under the guard, and says so with the collation it runs under") + func replaceStatement() throws { + let plan = try #require(try MongoDocumentWritePlan.make( + collection: "events", + operation: .replace(original: stored, edited: #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"6"}}"#), + canonicalize: canonical + )) + let filter = try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: stored)) + let replacement = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"6"}}"# + #expect(plan.write == .replace(filter: filter, replacement: replacement)) + #expect(plan.statement == #"db.events.replaceOne(\#(filter), \#(replacement), {"collation":{"locale":"simple"}})"#) + } + + @Test("An edit that changes nothing writes nothing") + func unchangedEdit() throws { + let plan = try MongoDocumentWritePlan.make( + collection: "events", + operation: .replace(original: stored, edited: "{\n \"n\": {\"$numberInt\": \"5\"}\n}"), + canonicalize: { try MongoDocumentText(parsing: $0).compactText } + ) + #expect(plan == nil) + } + + @Test("The guard is built from the document as it was opened, not as it was edited") + func guardFollowsTheOriginal() throws { + let plan = try #require(try MongoDocumentWritePlan.make( + collection: "events", + operation: .replace(original: stored, edited: #"{"n":{"$numberLong":"5"}}"#), + canonicalize: canonical + )) + guard case .replace(let filter, let replacement) = plan.write else { + Issue.record("Expected a replace") + return + } + #expect(filter.contains(#"{"$literal":\#(stored)}"#)) + #expect(replacement == #"{"_id":{"$numberInt":"1"},"n":{"$numberLong":"5"}}"#) + } + + @Test("Both texts are read strictly before libbson sees either") + func replaceReadsStrictly() { + #expect(throws: MongoDocumentText.Refusal.self) { + try MongoDocumentWritePlan.make( + collection: "events", + operation: .replace(original: stored, edited: #"{"n":1,"n":2}"#), + canonicalize: canonical + ) + } } } diff --git a/TableProTests/Plugins/MongoEditableDocumentTests.swift b/TableProTests/Plugins/MongoEditableDocumentTests.swift new file mode 100644 index 0000000000..c0457de264 --- /dev/null +++ b/TableProTests/Plugins/MongoEditableDocumentTests.swift @@ -0,0 +1,277 @@ +// +// MongoEditableDocumentTests.swift +// TableProTests +// + +import Foundation +import Testing + +private struct FakeLibbson: MongoDocumentCodec { + var readsBackExactly = true + var filterSize: Int? = 100 + var documentSize = 100 + + func isSameDocument(_ text: String, asCanonical canonical: String) -> Bool { + readsBackExactly + } + + func bsonSize(of json: String) -> Int? { + json.contains(#""$expr""#) ? filterSize : documentSize + } +} + +/// The steps of one read in the order they ran, with a cancel that lands during one of them. +private final class ReadSteps { + var serverVersion: String? = "7.0.43" + var listCollectionsReply: () throws -> [String: Any]? = { nil } + var cancelDuring: String? + private(set) var ran: [String] = [] + private var isCancelled = false + + func read() throws -> [MongoStoredDocument] { + try MongoEditableDocument.readStored( + serverVersion: { + run("version") + return serverVersion + }, + listCollectionsReply: { + run("listCollections") + return try listCollectionsReply() + }, + storedDocuments: { + run("find") + return [] + }, + checkCancelled: { + if isCancelled { throw CancellationError() } + } + ) + } + + private func run(_ step: String) { + ran.append(step) + if step == cancelDuring { isCancelled = true } + } +} + +struct MongoEditableDocumentTests { + private let identity: MongoDocumentIdentity + private let stored = #"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberLong" : "5" }, "d" : { "$numberDouble" : "1.0" } }"# + + init() throws { + identity = try MongoDocumentIdentity(locator: #"{"$numberInt":"1"}"#) + } + + private func open( + _ documents: [MongoStoredDocument], + codec: MongoDocumentCodec = FakeLibbson() + ) throws -> String? { + try MongoEditableDocument.text(for: identity, among: documents, codec: codec) + } + + @Test("A document that no longer exists opens as nil, even when the query matched another one") + func gone() throws { + #expect(try open([]) == nil) + let lenientMatch = MongoStoredDocument(canonical: #"{ "_id" : { "$numberDouble" : "1.0" } }"#, isRepresentable: true) + #expect(try open([lenientMatch]) == nil) + } + + @Test("Two documents with the locator are refused rather than guessed between") + func ambiguous() { + let document = MongoStoredDocument(canonical: stored, isRepresentable: true) + #expect(throws: MongoDBDocumentEditingError.ambiguousIdentity) { + try open([document, document]) + } + } + + @Test("The readable form is shown when libbson reads it back as the same document") + func readable() throws { + let text = try open([MongoStoredDocument(canonical: stored, isRepresentable: true)]) + #expect(text == """ + { + "_id": 1, + "n": {"$numberLong":"5"}, + "d": 1.0 + } + """) + } + + @Test("The canonical form is shown when the readable one would not read back the same") + func canonicalFallback() throws { + let text = try open( + [MongoStoredDocument(canonical: stored, isRepresentable: true)], + codec: FakeLibbson(readsBackExactly: false) + ) + #expect(text?.contains(#""_id": {"$numberInt":"1"}"#) == true) + #expect(text?.contains(#""d": {"$numberDouble":"1.0"}"#) == true) + } + + @Test("A document libbson cannot write back exactly is refused when it opens") + func inexact() { + #expect(throws: MongoDBDocumentEditingError.inexactAsText) { + try open([MongoStoredDocument(canonical: stored, isRepresentable: false)]) + } + } + + @Test("A stored top-level $ field and a repeated field are refused when the document opens") + func unreadableAsText() { + #expect(throws: MongoDocumentText.Refusal.operatorField("$p")) { + try open([MongoStoredDocument(canonical: #"{ "_id" : { "$numberInt" : "1" }, "$p" : 1 }"#, isRepresentable: true)]) + } + #expect(throws: MongoDocumentText.Refusal.duplicateField("a")) { + try open([MongoStoredDocument( + canonical: #"{ "_id" : { "$numberInt" : "1" }, "a" : 1, "a" : 2 }"#, + isRepresentable: true + )]) + } + } + + @Test("Timestamp(0, 0) in a top-level field is refused when the document opens") + func emptyTimestamp() { + let document = #"{ "_id" : { "$numberInt" : "1" }, "ts" : { "$timestamp" : { "t" : 0, "i" : 0 } } }"# + #expect(throws: MongoDBDocumentEditingError.emptyTimestamp("ts")) { + try open([MongoStoredDocument(canonical: document, isRepresentable: true)]) + } + } + + @Test("A guard libbson cannot read is refused as too deep") + func unreadableGuard() { + #expect(throws: MongoDocumentGuard.Refusal.tooDeep) { + try open([MongoStoredDocument(canonical: stored, isRepresentable: true)], codec: FakeLibbson(filterSize: nil)) + } + } + + @Test("A document whose guarded replace would pass 16 MB is refused when it opens") + func tooLarge() throws { + let limit = MongoEditableDocument.commandSizeLimit + let document = MongoStoredDocument(canonical: stored, isRepresentable: true) + #expect(throws: MongoDBDocumentEditingError.tooLarge) { + try open([document], codec: FakeLibbson(filterSize: limit / 2 + 1, documentSize: limit / 2)) + } + #expect(try open([document], codec: FakeLibbson(filterSize: limit / 2, documentSize: limit / 2)) != nil) + } + + @Test("Only a plain collection is edited: a view, a time-series collection and an unknown kind are refused") + func namespaceKinds() { + func refusal(_ type: String?) -> MongoDBDocumentEditingError? { + var entry: [String: Any] = ["name": "c"] + entry["type"] = type + return MongoEditableDocument.namespaceRefusal(listCollectionsReply: ["cursor": ["firstBatch": [entry]]]) + } + #expect(refusal("collection") == nil) + #expect(refusal("view") == .view) + #expect(refusal("timeseries") == .timeSeries) + #expect(refusal("ledger") == .notACollection("ledger")) + #expect(refusal(nil) == nil) + #expect(MongoEditableDocument.namespaceRefusal(listCollectionsReply: [:]) == nil) + #expect(MongoEditableDocument.namespaceTypeCommand(for: #"a"b"#) == #"{"listCollections":1,"filter":{"name":"a\"b"}}"#) + } + + @Test("A NaN libbson cannot write back is refused for being a NaN") + func notANumberBeforeInexact() { + let document = #"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberDouble" : "NaN" } }"# + #expect(throws: MongoDocumentGuard.Refusal.notANumber) { + try open([MongoStoredDocument(canonical: document, isRepresentable: false)]) + } + #expect(throws: MongoDocumentGuard.Refusal.notANumber) { + try open([MongoStoredDocument(canonical: document, isRepresentable: true)]) + } + } + + @Test("The read asks for no limit, so documents the collation also finds cannot use it up") + func readOptions() { + #expect(MongoEditableDocument.readOptions(maxTimeMS: nil) == "{}") + #expect(MongoEditableDocument.readOptions(maxTimeMS: 30_000) == #"{"maxTimeMS":30000}"#) + } + + @Test("A read goes on past documents the collation found and keeps only exact matches") + func lenientMatchesAreReadPast() { + var matches = MongoExactMatches(identity: identity) + let lenient = [ + #"{ "_id" : { "$numberDouble" : "1.0" } }"#, + #"{ "_id" : { "$numberLong" : "1" } }"#, + #"{ "_id" : { "$numberDecimal" : "1.00" } }"# + ] + var asked = 0 + for canonical in lenient { + matches.consider(canonical) { + asked += 1 + return true + } + } + #expect(matches.documents.isEmpty) + #expect(!matches.isDecided) + #expect(asked == 0, "Only an exact match is read back through libbson") + + matches.consider(stored) { true } + #expect(matches.documents == [MongoStoredDocument(canonical: stored, isRepresentable: true)]) + #expect(!matches.isDecided) + } + + @Test("A second exact match decides the read, and what it gathered is refused as ambiguous") + func secondExactMatchDecides() { + var matches = MongoExactMatches(identity: identity) + matches.consider(#"{ "_id" : "1" }"#) { true } + matches.consider(stored) { true } + matches.consider(#"{ "_id" : { "$numberLong" : "1" } }"#) { true } + #expect(!matches.isDecided) + matches.consider(stored) { false } + #expect(matches.isDecided) + matches.consider(stored) { true } + #expect(matches.documents.count == 2) + #expect(throws: MongoDBDocumentEditingError.ambiguousIdentity) { + try open(matches.documents) + } + } + + @Test("A cancel that lands during one step of the read stops the next one") + func cancelStopsTheNextStep() throws { + let duringVersion = ReadSteps() + duringVersion.cancelDuring = "version" + #expect(throws: CancellationError.self) { try duringVersion.read() } + #expect(duringVersion.ran == ["version"]) + + let duringListing = ReadSteps() + duringListing.cancelDuring = "listCollections" + #expect(throws: CancellationError.self) { try duringListing.read() } + #expect(duringListing.ran == ["version", "listCollections"]) + + let uncancelled = ReadSteps() + _ = try uncancelled.read() + #expect(uncancelled.ran == ["version", "listCollections", "find"]) + } + + @Test("A collection the user cannot list is still read, but a cancel while listing it stops the read") + func listingFailure() throws { + let unauthorized = ReadSteps() + unauthorized.listCollectionsReply = { throw NSError(domain: "MongoDB", code: 13) } + _ = try unauthorized.read() + #expect(unauthorized.ran == ["version", "listCollections", "find"]) + + let cancelled = ReadSteps() + cancelled.listCollectionsReply = { throw CancellationError() } + #expect(throws: CancellationError.self) { try cancelled.read() } + #expect(cancelled.ran == ["version", "listCollections"]) + } + + @Test("An old server and a view are refused before the documents are read") + func refusedBeforeTheRead() { + let old = ReadSteps() + old.serverVersion = "3.6.23" + #expect(throws: MongoDBDocumentEditingError.serverTooOld) { try old.read() } + #expect(old.ran == ["version"]) + + let view = ReadSteps() + view.listCollectionsReply = { ["cursor": ["firstBatch": [["name": "v", "type": "view"]]]] } + #expect(throws: MongoDBDocumentEditingError.view) { try view.read() } + #expect(view.ran == ["version", "listCollections"]) + } + + @Test("Editing needs MongoDB 4.0, and a server whose version is unknown is let through") + func serverFloor() { + #expect(!MongoDBCapabilities.parse("3.6.23").supportsDocumentReplaceGuard) + #expect(MongoDBCapabilities.parse("4.0.0").supportsDocumentReplaceGuard) + #expect(MongoDBCapabilities.parse("7.0.43").supportsDocumentReplaceGuard) + #expect(MongoDBCapabilities.parse(nil).supportsDocumentReplaceGuard) + } +} diff --git a/TableProTests/Plugins/MongoResultRebuildTests.swift b/TableProTests/Plugins/MongoResultRebuildTests.swift new file mode 100644 index 0000000000..42fefd40da --- /dev/null +++ b/TableProTests/Plugins/MongoResultRebuildTests.swift @@ -0,0 +1,59 @@ +// +// MongoResultRebuildTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct MongoResultRebuildTests { + private func result() -> PluginQueryResult { + var result = PluginQueryResult( + columns: ["_id"], + columnTypeNames: ["Int32"], + rows: [["1"], ["2"], ["3"]], + rowsAffected: 0, + timing: PluginQueryTiming(total: 2, firstRow: 1, server: 0.5), + columnMeta: [PluginColumnInfo(name: "_id", dataType: "Int32", isPrimaryKey: true)] + ) + result.rowLocators = [#"{"$numberInt":"1"}"#, nil, #"{"$numberInt":"3"}"#] + return result + } + + private func expectCarried(_ rebuilt: PluginQueryResult, from original: PluginQueryResult) { + #expect(rebuilt.timing == original.timing) + #expect(rebuilt.columnMeta?.map(\.name) == original.columnMeta?.map(\.name)) + } + + @Test("Setting the affected count or a status keeps the locators, the column metadata and the timing") + func rebuildsKeepEverything() { + let original = result() + let affected = original.withRowsAffected(3) + #expect(affected.rowsAffected == 3) + #expect(affected.rowLocators == original.rowLocators) + expectCarried(affected, from: original) + let status = original.withStatus("printed") + #expect(status.statusMessage == "printed") + #expect(status.rowLocators == original.rowLocators) + expectCarried(status, from: original) + } + + @Test("Capping to the row cap cuts the locators with the rows") + func cappingSlicesLocators() { + let original = result() + let capped = original.capped(to: 2) + #expect(capped.rows.count == 2) + #expect(capped.isTruncated) + #expect(capped.rowLocators == [#"{"$numberInt":"1"}"#, nil]) + expectCarried(capped, from: original) + #expect(original.capped(to: 5).rowLocators == original.rowLocators) + } + + @Test("Locators that do not pair one to one with the rows are dropped") + func mismatchedLocators() { + #expect(result().withRowLocators(["a"]).rowLocators == nil) + #expect(result().withRowLocators(nil).rowLocators == nil) + #expect(result().withRowLocators(["a", nil, "c"]).rowLocators == ["a", nil, "c"]) + } +} diff --git a/TableProTests/Plugins/MongoWriteFailureTests.swift b/TableProTests/Plugins/MongoWriteFailureTests.swift index 24b87c9533..3c99c1cebd 100644 --- a/TableProTests/Plugins/MongoWriteFailureTests.swift +++ b/TableProTests/Plugins/MongoWriteFailureTests.swift @@ -84,6 +84,29 @@ struct MongoWriteFailureTests { #expect(MongoWriteFailure.read(fromReply: "") == nil) #expect(MongoWriteFailure.read(fromReply: "[1, 2]") == nil) } + + @Test("A write concern error in a CRUD reply's array says the write was applied") + func crudWriteConcernErrors() { + let reply = """ + {"insertedCount":{"$numberInt":"0"},"matchedCount":{"$numberInt":"1"},"modifiedCount":{"$numberInt":"1"},\ + "upsertedCount":{"$numberInt":"0"},"writeConcernErrors":[{"code":{"$numberInt":"64"},\ + "errmsg":"waiting for replication timed out"}]} + """ + #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( + code: 64, + message: MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out") + )) + } + + @Test("A CRUD reply's writeErrors array gives the server's own message") + func crudWriteErrors() { + let reply = """ + {"insertedCount":{"$numberInt":"0"},"matchedCount":{"$numberInt":"0"},"modifiedCount":{"$numberInt":"0"},\ + "upsertedCount":{"$numberInt":"0"},"writeErrors":[{"index":{"$numberInt":"0"},"code":{"$numberInt":"121"},\ + "errmsg":"Document failed validation"}]} + """ + #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure(code: 121, message: "Document failed validation")) + } } struct MongoScriptStatementFailureTests { diff --git a/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift b/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift new file mode 100644 index 0000000000..72b48b5d2b --- /dev/null +++ b/TableProTests/Views/Main/DocumentEditingAvailabilityTests.swift @@ -0,0 +1,129 @@ +// +// DocumentEditingAvailabilityTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct DocumentEditingAvailabilityTests { + private let locators: [String?] = [#"{"$numberInt":"1"}"#, #"{"$numberInt":"2"}"#, nil, #"{"$numberInt":"4"}"#] + + private func makeCoordinator( + type: DatabaseType = .mongodb, + tabType: TabType = .table, + locators: [String?]? = nil + ) -> MainContentCoordinator { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(type: type), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = tabType == .table + ? QueryTab(title: "people", query: "db.people.find({})", tabType: .table, tableName: "people") + : QueryTab(title: "Query", query: "db.people.find({})", tabType: .query) + tab.execution.lastExecutedAt = Date() + tab.display.resultsViewMode = .data + tab.tableContext.isEditable = true + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")], [.text("3"), .text("Cleo")], + [.text("4"), .text("Bob")]], + columns: ["_id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + rowLocators: locators ?? self.locators + ), + for: tab.id + ) + coordinator.changeManager.configureForTable( + tableName: "people", + columns: ["_id", "name"], + primaryKeyColumns: ["_id"], + databaseType: type, + generatedColumns: [] + ) + return coordinator + } + + @Test("A collection row the driver gave a locator can be edited as a document") + func offeredOnALocatedRow() { + let coordinator = makeCoordinator() + #expect(coordinator.documentEditingAvailable) + #expect(coordinator.canEditDocument(atDisplayRow: 0)) + #expect(coordinator.documentLocator(forDisplayRow: 1) == #"{"$numberInt":"2"}"#) + } + + @Test("A row with no locator, or no row at all, is not offered") + func notOfferedWithoutALocator() { + let coordinator = makeCoordinator() + #expect(!coordinator.canEditDocument(atDisplayRow: 2)) + #expect(!coordinator.canEditDocument(atDisplayRow: 9)) + } + + @Test("A value filter resolves the shown row to its own document, not the one at that storage index") + func valueFilterResolvesDisplayRows() throws { + let coordinator = makeCoordinator() + let tabId = try #require(coordinator.tabManager.selectedTab?.id) + var filter = GridValueFilterState() + filter.set(ColumnValueFilter(selectedValues: ["Bob"], includesNull: false), columnName: "name", forColumn: 1) + coordinator.setValueFilter(filter, forTab: tabId) + + #expect(coordinator.documentLocator(forDisplayRow: 0) == #"{"$numberInt":"2"}"#) + #expect(coordinator.documentLocator(forDisplayRow: 1) == #"{"$numberInt":"4"}"#) + } + + @Test("A query tab never offers Edit Document, whatever its rows carry") + func queryTabIsNotOffered() { + let coordinator = makeCoordinator(tabType: .query) + #expect(!coordinator.documentEditingAvailable) + #expect(!coordinator.canEditDocument(atDisplayRow: 0)) + } + + @Test("Staged grid edits keep Edit Document away") + func stagedChangesAreRefused() { + let coordinator = makeCoordinator() + coordinator.changeManager.recordCellChange( + rowID: .existing(1), + columnIndex: 1, + columnName: "name", + oldValue: .text("Bob"), + newValue: .text("Rob"), + originalRow: [.text("2"), .text("Bob")] + ) + #expect(coordinator.changeManager.hasChanges) + #expect(!coordinator.canEditDocument(atDisplayRow: 0)) + } + + @Test("A read-only connection does not offer Edit Document") + func readOnlyIsRefused() { + let coordinator = makeCoordinator() + coordinator.toolbarState.safeModeLevel = .readOnly + #expect(!coordinator.canEditDocument(atDisplayRow: 0)) + } + + @Test("An engine that stores rows does not offer it") + func rowEngineIsRefused() { + let coordinator = makeCoordinator(type: .postgresql) + #expect(!coordinator.canEditDocument(atDisplayRow: 0)) + } + + @Test("The sheet opens on the locator it was given, as an edit of that collection") + func presentsAnEdit() { + let coordinator = makeCoordinator() + coordinator.presentEditDocument(locator: #"{"$numberInt":"4"}"#) + guard case .documentEditor(let request) = coordinator.activeSheet else { + Issue.record("Expected the document editor") + return + } + #expect(request.table == "people") + #expect(request.kind == .edit(locator: #"{"$numberInt":"4"}"#)) + } +} diff --git a/TableProTests/Views/Main/DocumentEditorPresentationTests.swift b/TableProTests/Views/Main/DocumentEditorPresentationTests.swift new file mode 100644 index 0000000000..993888e9c2 --- /dev/null +++ b/TableProTests/Views/Main/DocumentEditorPresentationTests.swift @@ -0,0 +1,93 @@ +// +// DocumentEditorPresentationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct DocumentEditorPresentationTests { + private let edit = DocumentEditorRequest.Kind.edit(locator: "1") + + @Test("An insert starts editable and an edit starts by loading") + func initialPhase() { + #expect(DocumentEditorPresentation.initialPhase(for: .insert) == .editing) + #expect(DocumentEditorPresentation.initialPhase(for: edit) == .loading) + } + + @Test("While a write is on its way the text is read only and the sheet cannot be closed") + func savingLocksTheSheet() { + for kind in [DocumentEditorRequest.Kind.insert, edit] { + let saving = DocumentEditorPresentation(kind: kind, phase: .saving) + #expect(!saving.isEditable) + #expect(!saving.canSave) + #expect(!saving.canCancel) + #expect(saving.dismissDisabled) + #expect(saving.showsEditor) + } + } + + @Test("While the document loads there is nothing to edit, and Cancel still works") + func loading() { + let loading = DocumentEditorPresentation(kind: edit, phase: .loading) + #expect(!loading.showsEditor) + #expect(!loading.isEditable) + #expect(!loading.showsSave) + #expect(loading.canCancel) + #expect(!loading.dismissDisabled) + #expect(loading.message == nil) + } + + @Test("A document that is gone, or failed to load, offers only Close with the reason") + func noDocument() { + let missing = DocumentEditorPresentation(kind: edit, phase: .missing) + #expect(!missing.showsEditor) + #expect(!missing.showsSave) + #expect(missing.cancelTitle == String(localized: "Close")) + #expect(missing.message != nil) + let failed = DocumentEditorPresentation(kind: edit, phase: .loadFailed("refused")) + #expect(failed.message == "refused") + #expect(!failed.showsSave) + } + + @Test("Editing allows saving under the title and button of its kind") + func editing() { + let insert = DocumentEditorPresentation(kind: .insert, phase: .editing) + #expect(insert.isEditable && insert.canSave && insert.canCancel) + #expect(insert.title == String(localized: "Insert Document")) + #expect(insert.saveTitle == String(localized: "Insert")) + let editing = DocumentEditorPresentation(kind: edit, phase: .editing) + #expect(editing.title == String(localized: "Edit Document")) + #expect(editing.saveTitle == String(localized: "Save")) + #expect(editing.hint != insert.hint) + } +} + +@MainActor +struct DocumentEditingOperationTests { + @Test("An insert writes the text as a new document") + func insert() throws { + #expect(try DocumentEditing.operation(for: .insert, text: "{}", original: nil) == .insert(document: "{}")) + } + + @Test("An edit replaces the document it was read as") + func edit() throws { + let operation = try DocumentEditing.operation(for: .edit(locator: "1"), text: "{\"a\":2}", original: "{\"a\":1}") + #expect(operation == .replace(original: "{\"a\":1}", edited: "{\"a\":2}")) + } + + @Test("An edit whose document never loaded is refused, never written as a new document") + func editWithoutOriginal() { + #expect(throws: DocumentEditingError.documentNotLoaded) { + try DocumentEditing.operation(for: .edit(locator: "1"), text: "{}", original: nil) + } + } + + @Test("History names the operation by its kind") + func operationDescription() { + #expect(DocumentEditing.operationDescription(for: .insert) == String(localized: "Insert Document")) + #expect(DocumentEditing.operationDescription(for: .edit(locator: "1")) == String(localized: "Edit Document")) + } +} diff --git a/docs/databases/mongodb.mdx b/docs/databases/mongodb.mdx index 8145037bd8..ebfca46b09 100644 --- a/docs/databases/mongodb.mdx +++ b/docs/databases/mongodb.mdx @@ -118,6 +118,16 @@ A field exists only in the documents that hold it, so a collection with no docum The text is Extended JSON: quote every field name, and write an ObjectId as `{"$oid": "…"}`, a date as `{"$date": "2024-05-01T10:00:00Z"}` and a decimal as `{"$numberDecimal": "1.10"}`. A whole number is stored as a 32-bit integer, or as a 64-bit one when it does not fit; `{"$numberLong": "5"}` stores a small 64-bit integer. A number with a decimal point is a double. Fields are stored in the order written. Leave out `_id` and the server generates one. +## Editing documents + +A cell edits one value. To add, rename or remove a field, select one row and choose **Edit > Edit Document…**, or choose **Edit Document…** on the row's context menu. It works on a collection tab with no unsaved grid edits, not on a query tab. + +The sheet reads the document from the server again, so it shows what is stored now rather than what the grid loaded. A value reads as plain JSON when that stores the same type, and keeps its wrapper when it would not: a 64-bit integer small enough for 32 bits stays `{"$numberLong": "5"}`, and a date before 1970 or after 9999 stays `{"$date": {"$numberLong": "…"}}`. + +**Save** replaces the whole document with the text, in the order written. A field left out of the text is removed. A dotted name, or a name starting with `$` inside a subdocument, is stored as that exact name rather than read as a path. `_id` cannot change, and leaving it out keeps it. + +If anyone changed or deleted the document after the sheet opened, nothing is saved and the sheet says so. Copy your text, close the sheet and open the document again. + ## Writing queries Queries run through JavaScriptCore, so a statement is JavaScript and the whole language is @@ -210,7 +220,12 @@ New connections default to **Disabled**, and the driver has no TLS fallback: **P ## Limitations - 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…**. +- `_id` is read-only in the grid and in **Edit Document…**, and a row added with **Add Row** is inserted without one so the server generates it. To choose your own, use **Insert Document…**. +- **Edit Document…** opens a message instead of the text for a document it cannot write back exactly: a top-level field starting with `$`, a repeated field name, a subdocument shaped like `{"$numberInt": "5"}`, more than 28 levels of nesting, or a guarded save over 16 MB, which a document made mostly of numbers reaches at about 5 MB. Change such a document with `updateOne` in a query tab. +- The server replaces a top-level `Timestamp(0, 0)` with the current time whenever a whole document is written, so **Insert Document…** stores the time there and **Edit Document…** refuses a document holding one. `$set` in a query tab keeps it. +- **Edit Document…** also refuses a document holding a NaN, double or decimal, or JavaScript code with a scope. Change such a document with `updateOne` in a query tab. +- **Edit Document…** needs MongoDB 4.0 or later, and refuses a view and a time-series collection. Edit the collection a view reads, and change time-series documents with `updateMany` in a query tab. +- In a collection whose default collation is not simple, saving a document with a string `_id` reads the whole collection to find it. On a large collection, change the document with `updateOne` in a query tab instead. - 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. diff --git a/project.yml b/project.yml index ecd82d75d7..b335e0cf2f 100644 --- a/project.yml +++ b/project.yml @@ -521,6 +521,15 @@ targets: - Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift - Plugins/MongoDBDriverPlugin/MongoDocumentText.swift - Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift + - Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift + - Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift + - Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift + - Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift + - Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift + - Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift + - Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift + - Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift + - Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift - Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift - Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift - Plugins/MongoDBDriverPlugin/MongoShellCommandLine.swift diff --git a/scripts/check-mongodb-document-guard.sh b/scripts/check-mongodb-document-guard.sh new file mode 100755 index 0000000000..605ddff10a --- /dev/null +++ b/scripts/check-mongodb-document-guard.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# +# The Edit Document guard, built by the driver's own code and asked of a real server. +# +# MongoDocumentGuard writes an aggregation expression by hand, and it only works while it agrees +# with what the server's $eq, $type and $convert answer. The unit tests can pin its text but cannot +# ask a server. A guard that matches too much overwrites a change someone else made, and one that +# matches nothing or raises makes every save fail. So this compiles the guard's own sources with +# swiftc against the libbson and libmongoc the plugin links, inserts each document exactly as its +# canonical Extended JSON reads, and counts what the filter matches under the simple collation the +# save runs under: the unchanged document must match once, every mutation must match nothing, and +# neither order of the two $and operands may raise. A NaN and code with a scope are compared by +# value, so no filter can see a change to one, and the guard must refuse to be built for them. +# +# Usage: +# scripts/check-mongodb-document-guard.sh [mongodb-uri] +# +# The default URI is mongodb://127.0.0.1:27017. Needs MongoDB 4.0 or later and the static libraries +# from scripts/download-libs.sh. Writes only to a database named tablepro_guard_check_, which +# it drops. Exits non-zero on a disagreement. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +URI="${1:-mongodb://127.0.0.1:27017}" +PLUGIN="$ROOT/Plugins/MongoDBDriverPlugin" +LIBS="$ROOT/Libs" + +for lib in "$LIBS/libmongoc.a" "$LIBS/libbson.a" "$LIBS/dylibs/libssl.3.dylib" "$LIBS/dylibs/libcrypto.3.dylib"; do + [ -e "$lib" ] || { + echo "missing $lib (run scripts/download-libs.sh)" >&2 + exit 3 + } +done + +# A private directory, matching every sibling check script. /tmp is world-writable, so a fixed +# name is something another local user can pre-create and control. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/main.swift" <<'SWIFT' +import CLibMongoc +import Foundation + +typealias Value = MongoDocumentText.Value + +struct Case { + let name: String + let original: String + var collation: String? + let mutations: [(String, String)] +} + +func document(_ fields: String) -> String { + #"{"_id":{"$numberInt":"1"},\#(fields)}"# +} + +func nested(_ depth: Int, innermost: String) -> String { + var value = innermost + for _ in 0 ..< depth - 1 { + value = #"{"k":\#(value)}"# + } + return document(#""v":\#(value)"#) +} + +func intArray(_ count: Int, replacingLastWith last: String? = nil) -> String { + var elements = (0 ..< count).map { #"{"$numberInt":"\#($0)"}"# } + if let last { elements[count - 1] = last } + return document(#""a":[\#(elements.joined(separator: ","))]"#) +} + +let numbers = #""a":{"$numberInt":"5"},"b":{"c":[{"$numberInt":"1"},{"$numberDouble":"0.0"},{"$numberDecimal":"1.0"}]},"s":"Hello""# +let zoo = #""oid":{"$oid":"65f0a1b2c3d4e5f607182930"},"sym":{"$symbol":"s"},"u":{"$undefined":true},"# + + #""code":{"$code":"y"},"mn":{"$minKey":1},"# + + #""mx":{"$maxKey":1},"ts":{"$timestamp":{"t":5,"i":1}},"bin":{"$binary":{"base64":"AQID","subType":"80"}},"# + + #""re":{"$regularExpression":{"pattern":"a.b","options":"i"}},"d":{"$date":{"$numberLong":"-1000"}},"# + + #""l":{"$numberLong":"9007199254740993"},"n":null,"t":true,"# + + #""dinf":{"$numberDecimal":"Infinity"},"inf":{"$numberDouble":"-Infinity"}"# + +/// Values the server compares by what they mean, which no filter can see past, so the guard has to +/// refuse to be built for them rather than build one that lets a change through. +let refused: [(String, MongoDocumentGuard.Refusal)] = [ + (document(#""n":{"$numberDouble":"NaN"}"#), .notANumber), + (document(#""n":{"$numberDecimal":"NaN"}"#), .notANumber), + (document(#""a":[{"o":{"$numberDouble":"NaN"}}]"#), .notANumber), + (document(#""c":{"$code":"x","$scope":{"a":{"$numberInt":"1"}}}"#), .codeWithScope) +] + +let cases = [ + Case(name: "numbers and strings", original: document(numbers), mutations: [ + ("int32 to int64", document(numbers.replacingOccurrences(of: #""a":{"$numberInt":"5"}"#, with: #""a":{"$numberLong":"5"}"#))), + ("int32 to double", document(numbers.replacingOccurrences(of: #""a":{"$numberInt":"5"}"#, with: #""a":{"$numberDouble":"5.0"}"#))), + ("nested int32 to int64", document(numbers.replacingOccurrences(of: #"[{"$numberInt":"1"}"#, with: #"[{"$numberLong":"1"}"#))), + ("0.0 to -0.0", document(numbers.replacingOccurrences(of: #""0.0""#, with: #""-0.0""#))), + ("decimal 1.0 to 1.00", document(numbers.replacingOccurrences(of: #""1.0"}]"#, with: #""1.00"}]"#))), + ("decimal to double", document(numbers.replacingOccurrences(of: #"{"$numberDecimal":"1.0"}"#, with: #"{"$numberDouble":"1.0"}"#))), + ("decimal to object", document(numbers.replacingOccurrences(of: #"{"$numberDecimal":"1.0"}"#, with: #"{"x":{"$numberInt":"1"}}"#))), + ("zero to array", document(numbers.replacingOccurrences(of: #"{"$numberDouble":"0.0"}"#, with: "[]"))), + ("case of a string", document(numbers.replacingOccurrences(of: "Hello", with: "hello"))), + ("string to symbol", document(numbers.replacingOccurrences(of: #""Hello""#, with: #"{"$symbol":"Hello"}"#))), + ("fields reordered", document(#""s":"Hello","a":{"$numberInt":"5"},"b":{"c":[{"$numberInt":"1"},{"$numberDouble":"0.0"},{"$numberDecimal":"1.0"}]}"#)), + ("field removed", document(#""a":{"$numberInt":"5"},"s":"Hello""#)), + ("null field added", document(numbers + #","z":null"#)), + ("object to scalar", document(#""a":{"$numberInt":"5"},"b":{"$numberInt":"3"},"s":"Hello""#)), + ("array to object", document(#""a":{"$numberInt":"5"},"b":{"c":{"0":{"$numberInt":"1"},"1":{"$numberDouble":"0.0"},"2":{"$numberDecimal":"1.0"}}},"s":"Hello""#)) + ]), + Case(name: "field names", original: #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}},"":"e","a.b":{"$numberInt":"2"},"x":{"$w":{"$numberInt":"1"},"n.m":[{"$numberDecimal":"0E+10"}]}}"#, mutations: [ + ("_id fields reordered", #"{"_id":{"b":{"$numberInt":"2"},"a":{"$numberInt":"1"}},"":"e","a.b":{"$numberInt":"2"},"x":{"$w":{"$numberInt":"1"},"n.m":[{"$numberDecimal":"0E+10"}]}}"#), + ("$w int32 to int64", #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}},"":"e","a.b":{"$numberInt":"2"},"x":{"$w":{"$numberLong":"1"},"n.m":[{"$numberDecimal":"0E+10"}]}}"#), + ("decimal 0E+10 to 0", #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}},"":"e","a.b":{"$numberInt":"2"},"x":{"$w":{"$numberInt":"1"},"n.m":[{"$numberDecimal":"0"}]}}"#), + ("empty name's value", #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}},"":"E","a.b":{"$numberInt":"2"},"x":{"$w":{"$numberInt":"1"},"n.m":[{"$numberDecimal":"0E+10"}]}}"#) + ]), + Case(name: "every type", original: document(zoo), mutations: [ + ("decimal Infinity to double", document(zoo.replacingOccurrences(of: #"{"$numberDecimal":"Infinity"}"#, with: #"{"$numberDouble":"Infinity"}"#))), + ("undefined to null", document(zoo.replacingOccurrences(of: #"{"$undefined":true}"#, with: "null"))), + ("symbol to string", document(zoo.replacingOccurrences(of: #"{"$symbol":"s"}"#, with: #""s""#))), + ("code to code with scope", document(zoo.replacingOccurrences(of: #"{"$code":"y"}"#, with: #"{"$code":"y","$scope":{}}"#))), + ("binary subtype", document(zoo.replacingOccurrences(of: #""subType":"80""#, with: #""subType":"00""#))), + ("regex options", document(zoo.replacingOccurrences(of: #""options":"i""#, with: #""options":"m""#))), + ("timestamp increment", document(zoo.replacingOccurrences(of: #""i":1"#, with: #""i":2"#))) + ]), + Case(name: "string under a case-insensitive default collation", original: document(#""s":"Hello""#), + collation: #"{"locale":"en","strength":2}"#, + mutations: [("case of a string", document(#""s":"hello""#))]), + Case(name: "deepest document", original: nested(MongoDocumentGuard.maximumDepth, innermost: #"{"$numberInt":"1"}"#), mutations: [ + ("innermost int32 to int64", nested(MongoDocumentGuard.maximumDepth, innermost: #"{"$numberLong":"1"}"#)) + ]), + Case(name: "10,000-element array", original: intArray(10_000), mutations: [ + ("last element int32 to int64", intArray(10_000, replacingLastWith: #"{"$numberLong":"9999"}"#)) + ]) +] + +func bson(_ json: String) -> OpaquePointer { + var error = bson_error_t() + guard let parsed = json.withCString({ bson_new_from_json($0, -1, &error) }) else { + let message = withUnsafeBytes(of: &error.message) { String(cString: $0.bindMemory(to: CChar.self).baseAddress!) } + FileHandle.standardError.write(Data("libbson cannot read: \(message)\n\(json.prefix(300))\n".utf8)) + exit(2) + } + return parsed +} + +func errorText(_ error: inout bson_error_t) -> String { + withUnsafeBytes(of: &error.message) { String(cString: $0.bindMemory(to: CChar.self).baseAddress!) } +} + +/// The same filter with its two `$and` operands swapped, since the server may evaluate either first. +func reversed(_ filter: String) throws -> String { + let wrapper = try MongoDocumentText(parsing: #"{"f":\#(filter)}"#) + guard case .object(let members) = try require(wrapper.value(of: "f")), + let expression = members.first(where: { $0.key == "$expr" }), + case .object(let conditions) = expression.value, + let conjunction = conditions.first, + case .array(let operands) = conjunction.value else { + throw MongoDocumentGuard.Refusal.unreadableValue + } + let swapped = Value.object([MongoDocumentText.Member(key: "$and", value: .array(operands.reversed()))]) + let rebuilt = members.map { $0.key == "$expr" ? MongoDocumentText.Member(key: "$expr", value: swapped) : $0 } + return Value.object(rebuilt).compactText +} + +func require(_ value: T?) throws -> T { + guard let value else { throw MongoDocumentGuard.Refusal.unreadableValue } + return value +} + +@main +enum GuardCheck { + static func main() { + mongoc_init() + let uri = CommandLine.arguments[1] + guard let client = mongoc_client_new(uri) else { + FileHandle.standardError.write(Data("cannot read the URI \(uri)\n".utf8)) + exit(3) + } + let database = mongoc_client_get_database(client, "tablepro_guard_check_\(getpid())") + var run = Run() + for (index, check) in cases.enumerated() { + run.check(check, in: database, named: "c\(index)") + } + for (original, refusal) in refused { + run.expectRefusal(original, refusal) + } + _ = mongoc_database_drop(database, nil) + guard run.failures == 0 else { + print("\n\(run.failures) disagreement(s)") + exit(1) + } + print("\nthe guard agrees with the server") + } +} + +struct Run { + let simple = bson(#"{"collation":{"locale":"simple"}}"#) + let unvalidated = bson(#"{"validate":false}"#) + var failures = 0 + + mutating func check(_ check: Case, in database: OpaquePointer?, named name: String) { + var error = bson_error_t() + let options = check.collation.map { bson(#"{"collation":\#($0)}"#) } + guard let collection = mongoc_database_create_collection(database, name, options, &error) else { + fail("\(check.name): could not create the collection: \(errorText(&error))") + return + } + let filter: String + let flipped: String + do { + filter = try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: check.original)) + flipped = try reversed(filter) + } catch { + fail("\(check.name): the guard could not be built: \(error)") + return + } + guard store(collection, check.original) else { return } + expect("\(check.name): unchanged document matches", [count(collection, filter), count(collection, flipped)], "1") + for (mutation, json) in check.mutations { + guard store(collection, json) else { continue } + expect("\(check.name): \(mutation) matches nothing", [count(collection, filter), count(collection, flipped)], "0") + } + } + + func count(_ collection: OpaquePointer, _ filter: String) -> String { + let query = bson(filter) + defer { bson_destroy(query) } + var error = bson_error_t() + let matched = mongoc_collection_count_documents(collection, query, simple, nil, nil, &error) + return matched < 0 ? "error: \(errorText(&error))" : String(matched) + } + + mutating func store(_ collection: OpaquePointer, _ json: String) -> Bool { + var error = bson_error_t() + let everything = bson("{}") + defer { bson_destroy(everything) } + _ = mongoc_collection_delete_many(collection, everything, nil, nil, &error) + let stored = bson(json) + defer { bson_destroy(stored) } + guard mongoc_collection_insert_one(collection, stored, unvalidated, nil, &error) else { + fail("could not insert: \(errorText(&error))") + return false + } + return true + } + + mutating func expect(_ label: String, _ answers: [String], _ expected: String) { + guard answers.allSatisfy({ $0 == expected }) else { + fail("\(label): expected \(expected) in both operand orders, got \(answers.joined(separator: " and "))") + return + } + print("ok \(label)") + } + + mutating func expectRefusal(_ original: String, _ refusal: MongoDocumentGuard.Refusal) { + do { + let filter = try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: original)) + fail("\(original): built a guard where \(refusal) was expected: \(filter.prefix(120))") + } catch let error as MongoDocumentGuard.Refusal where error == refusal { + print("ok refused \(refusal): \(original)") + } catch { + fail("\(original): expected \(refusal), got \(error)") + } + } + + mutating func fail(_ message: String) { + print("FAIL \(message)") + failures += 1 + } +} +SWIFT + +echo "Building the guard checker from $PLUGIN" +DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" xcrun swiftc -swift-version 6 -parse-as-library -O \ + -module-name GuardCheck \ + -I "$PLUGIN/CLibMongoc" \ + -Xcc "-I$PLUGIN/CLibMongoc/include" \ + -Xcc "-I$PLUGIN/CLibMongoc/include/libbson-1.0" \ + -Xcc "-I$PLUGIN/CLibMongoc/include/libmongoc-1.0" \ + -L "$LIBS/dylibs" \ + -Xlinker -force_load -Xlinker "$LIBS/libmongoc.a" \ + -Xlinker -force_load -Xlinker "$LIBS/libbson.a" \ + -lssl.3 -lcrypto.3 -lresolv -lz \ + -Xlinker -rpath -Xlinker "$LIBS/dylibs" \ + "$PLUGIN/MongoDocumentText.swift" \ + "$PLUGIN/MongoExtendedJsonType.swift" \ + "$PLUGIN/MongoDocumentGuard.swift" \ + "$PLUGIN/MongoDocumentIdentity.swift" \ + "$PLUGIN/MongoDBDocumentEditingError.swift" \ + "$WORK/main.swift" \ + -o "$WORK/check" > "$WORK/build.log" 2>&1 || { + cat "$WORK/build.log" >&2 + exit 2 +} + +"$WORK/check" "$URI" From 41e597d701a6e3f92c986573008d943be739ae9f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 27 Sep 2026 02:25:52 +0700 Subject: [PATCH 2/2] fix(plugin-mongodb): cancel Edit Document's read through the owner-scoped route --- .../DatabaseManager+DocumentWrite.swift | 19 +++---------------- .../DatabaseManager+ScopedDriver.swift | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift index d298c931a8..e9e9ff7644 100644 --- a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift +++ b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift @@ -57,23 +57,10 @@ extension DatabaseManager { /// The stored document a row's locator names, read on the connection the tab's data lives on. /// - /// Cancellable, so closing the sheet stops a read the server is still answering: it runs under - /// its own lease owner, which a cancel reaches and nothing else does. + /// Cancellable, so closing the sheet stops a read the server is still answering. func fetchDocument(locator: String, table: String, scope: DatabaseScope) async throws -> String? { - let owner = DriverLeaseOwner() - let connectionId = scope.connectionId - return try await withTaskCancellationHandler { - try await withScopedDriver( - scope: scope, - route: executionRoute(for: scope), - cancellation: .cancellableRead(owner) - ) { driver in - try await driver.fetchDocument(table: table, schema: scope.schema, locator: locator) - } - } onCancel: { - Task { @MainActor in - try? DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connectionId, delivery: .background) - } + try await withCancellableRead(scope: scope, route: executionRoute(for: scope)) { driver in + try await driver.fetchDocument(table: table, schema: scope.schema, locator: locator) } } diff --git a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift index 43134a00ec..99211b7e29 100644 --- a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift +++ b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift @@ -103,6 +103,24 @@ extension DatabaseManager { } } + /// A read that stops on the server once the task awaiting it is cancelled. It runs under a lease + /// owner of its own, so the cancel reaches this read and nothing else on the connection. + func withCancellableRead( + scope: DatabaseScope, + route: ScopedDriverRoute, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + let owner = DriverLeaseOwner() + let connectionId = scope.connectionId + return try await withTaskCancellationHandler { + try await withScopedDriver(scope: scope, route: route, cancellation: .cancellableRead(owner), body) + } onCancel: { + Task { @MainActor in + try? DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connectionId, delivery: .background) + } + } + } + /// A table tab's read is a SELECT the app built from the tab's own table, so it depends on /// nothing the session holds: no transaction, no temp table, no variable. That makes it the one /// kind of work that can follow a route change it waited through. A database switch on an engine