From 0212e35c76eb78864128ee43cd433ca408c072f5 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Thu, 26 Mar 2020 10:32:14 -0400 Subject: [PATCH 01/12] SWIFT-752 Implement transactions spec test runner --- Sources/MongoSwift/BSON/Document.swift | 2 +- Sources/MongoSwift/ClientSession.swift | 71 +- Sources/MongoSwift/MongoClient.swift | 15 +- .../MongoCollection+BulkWrite.swift | 13 +- Sources/MongoSwift/MongoError.swift | 14 +- Sources/MongoSwift/ReadPreference.swift | 14 +- Sources/MongoSwiftSync/ClientSession.swift | 12 + Tests/LinuxMain.swift | 7 + .../RetryableWritesTests.swift | 5 +- .../SpecTestRunner/FailPoint.swift | 4 + .../SpecTestRunner/Match.swift | 12 +- .../SpecTestRunner/SpecTest.swift | 110 ++- .../SpecTestRunner/TestOperation.swift | 744 +++++++++++++++--- .../SyncChangeStreamTests.swift | 2 +- .../TransactionsTests.swift | 139 ++++ etc/add_json_files.rb | 3 +- 16 files changed, 1042 insertions(+), 125 deletions(-) create mode 100644 Tests/MongoSwiftSyncTests/TransactionsTests.swift diff --git a/Sources/MongoSwift/BSON/Document.swift b/Sources/MongoSwift/BSON/Document.swift index 39dd07529..91d4b8969 100644 --- a/Sources/MongoSwift/BSON/Document.swift +++ b/Sources/MongoSwift/BSON/Document.swift @@ -258,7 +258,7 @@ extension Document { /// Helper function for copying elements from some source document to a destination document while /// excluding a non-zero number of keys - internal func copyElements(to otherDoc: inout Document, excluding keys: [String]) throws { + public func copyElements(to otherDoc: inout Document, excluding keys: [String]) throws { guard !keys.isEmpty else { throw InternalError(message: "No keys to exclude, use 'bson_copy' instead") } diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index 6273a4056..dff3bb77d 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -66,9 +66,74 @@ public final class ClientSession { /// The client used to start this session. public let client: MongoClient - /// The session ID of this session. This is internal for now because we only have a value available after we've - /// started the libmongoc session. - internal var id: Document? + /// The session ID of this session. We only have a value available after we've started the libmongoc session. + public var id: Document? + + /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. + public var serverId: Int? { + switch self.state { + case .notStarted, .ended: + return nil + case let .started(session, _): + return Int(mongoc_client_session_get_server_id(session)) + } + } + + /// Enum tracking the state of the transaction associated with this session. + public enum TransactionState: String, Decodable { + /// There is no transaction in progress. + case none + /// A transaction has been started, but no operation has been sent to the server. + case starting + /// A transaction is in progress. + case inProgress + /// The transaction was committed. + case committed + /// The transaction was aborted. + case aborted + + fileprivate var mongocTransactionState: mongoc_transaction_state_t { + switch self { + case .none: + return MONGOC_TRANSACTION_NONE + case .starting: + return MONGOC_TRANSACTION_STARTING + case .inProgress: + return MONGOC_TRANSACTION_IN_PROGRESS + case .committed: + return MONGOC_TRANSACTION_COMMITTED + case .aborted: + return MONGOC_TRANSACTION_ABORTED + } + } + + fileprivate init(mongocTransactionState: mongoc_transaction_state_t) { + switch mongocTransactionState { + case MONGOC_TRANSACTION_NONE: + self = .none + case MONGOC_TRANSACTION_STARTING: + self = .starting + case MONGOC_TRANSACTION_IN_PROGRESS: + self = .inProgress + case MONGOC_TRANSACTION_COMMITTED: + self = .committed + case MONGOC_TRANSACTION_ABORTED: + self = .aborted + default: + fatalError("Unexpected transaction state: \(mongocTransactionState)") + } + } + } + + /// The transaction state of this session. + public var transactionState: TransactionState? { + switch self.state { + case .notStarted, .ended: + return nil + case let .started(session, _): + return TransactionState(mongocTransactionState: mongoc_client_session_get_transaction_state(session)) + } + } /// The most recent cluster time seen by this session. This value will be nil if either of the following are true: /// - No operations have been executed using this session and `advanceClusterTime` has not been called. diff --git a/Sources/MongoSwift/MongoClient.swift b/Sources/MongoSwift/MongoClient.swift index 56383c429..52595424f 100644 --- a/Sources/MongoSwift/MongoClient.swift +++ b/Sources/MongoSwift/MongoClient.swift @@ -72,8 +72,19 @@ public struct ClientOptions: CodingStrategyProvider, Decodable { /// Specifies a WriteConcern to use for the client. public var writeConcern: WriteConcern? - private enum CodingKeys: CodingKey { - case retryWrites, retryReads, readConcern, writeConcern + private enum CodingKeys: String, CodingKey { + case retryWrites, retryReads, readConcern, writeConcern, w, readConcernLevel, mode = "readPreference" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.readConcern = (try? container.decode(ReadConcern.self, forKey: .readConcern)) ?? + (try? ReadConcern(container.decode(String.self, forKey: .readConcernLevel))) + self.readPreference = try? ReadPreference(container.decode(ReadPreference.Mode.self, forKey: .mode)) + self.retryReads = try? container.decode(Bool.self, forKey: .retryReads) + self.retryWrites = try? container.decode(Bool.self, forKey: .retryWrites) + self.writeConcern = (try? container.decode(WriteConcern.self, forKey: .writeConcern)) ?? + (try? WriteConcern(w: container.decode(WriteConcern.W.self, forKey: .w))) } /// Convenience initializer allowing any/all parameters to be omitted or optional. diff --git a/Sources/MongoSwift/MongoCollection+BulkWrite.swift b/Sources/MongoSwift/MongoCollection+BulkWrite.swift index 08047d2b0..558e0d039 100644 --- a/Sources/MongoSwift/MongoCollection+BulkWrite.swift +++ b/Sources/MongoSwift/MongoCollection+BulkWrite.swift @@ -214,8 +214,17 @@ internal struct BulkWriteOperation: Operation { mongoc_bulk_operation_execute(bulk, replyPtr, &error) } - let writeConcern = WriteConcern(from: mongoc_bulk_operation_get_write_concern(bulk)) - return (serverId, writeConcern.isAcknowledged) + var writeConcernAcknowledged: Bool + if let transactionState = session?.transactionState, transactionState != .none { + // Bulk write operations cannot have a write concern in a transaction. Default to + // writeConcernAcknowledged = true. + writeConcernAcknowledged = true + } else { + let writeConcern = WriteConcern(from: mongoc_bulk_operation_get_write_concern(bulk)) + writeConcernAcknowledged = writeConcern.isAcknowledged + } + + return (serverId, writeConcernAcknowledged) } let result = try BulkWriteResult(reply: reply, insertedIds: insertedIds) diff --git a/Sources/MongoSwift/MongoError.swift b/Sources/MongoSwift/MongoError.swift index 9f4d734cf..011f16165 100644 --- a/Sources/MongoSwift/MongoError.swift +++ b/Sources/MongoSwift/MongoError.swift @@ -96,7 +96,7 @@ public struct InternalError: RuntimeError { /// An error thrown when encountering a connection or socket related error. /// May contain labels providing additional information on the nature of the error. public struct ConnectionError: RuntimeError, LabeledError { - internal let message: String + public let message: String public let errorLabels: [String]? @@ -251,6 +251,18 @@ private func parseMongocError(_ error: bson_error_t, reply: Document?) -> MongoE message: message, errorLabels: errorLabels ) + case (MONGOC_ERROR_WRITE_CONCERN, _): + var writeConcernErrorLabels = + reply?["writeConcernError"]?.documentValue?["errorLabels"]?.arrayValue?.asArrayOf(String.self) + if let errorLabels = errorLabels { + writeConcernErrorLabels = Array(Set((writeConcernErrorLabels ?? []) + errorLabels)) + } + return CommandError( + code: ServerErrorCode(code.rawValue), + codeName: codeName, + message: message, + errorLabels: writeConcernErrorLabels + ) case (MONGOC_ERROR_STREAM, _): return ConnectionError(message: message, errorLabels: errorLabels) case (MONGOC_ERROR_SERVER_SELECTION, MONGOC_ERROR_SERVER_SELECTION_FAILURE): diff --git a/Sources/MongoSwift/ReadPreference.swift b/Sources/MongoSwift/ReadPreference.swift index 3cab46027..d188124f9 100644 --- a/Sources/MongoSwift/ReadPreference.swift +++ b/Sources/MongoSwift/ReadPreference.swift @@ -3,10 +3,10 @@ import CLibMongoC /// Represents a MongoDB read preference, indicating which member(s) of a replica set read operations should be /// directed to. /// - SeeAlso: https://docs.mongodb.com/manual/reference/read-preference/ -public struct ReadPreference { +public struct ReadPreference: Decodable { /// An enumeration of possible read preference modes. /// - SeeAlso: https://docs.mongodb.com/manual/core/read-preference/#read-preference-modes - public enum Mode: String { + public enum Mode: String, Decodable { /// Default mode. All operations read from the current replica set primary. case primary /// In most situations, operations read from the primary but if it is unavailable, operations read from @@ -92,6 +92,16 @@ public struct ReadPreference { /// the least network latency, irrespective of the member’s type. public static let nearest = ReadPreference(.nearest) + private enum CodingKeys: String, CodingKey { + case mode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let mode = try container.decode(Mode.self, forKey: .mode) + self.init(mode) + } + /** * Initializes a new `ReadPreference` with the mode `primaryPreferred`. With this mode, in most situations * operations read from the primary, but if it is unavailable, operations read from secondary members. diff --git a/Sources/MongoSwiftSync/ClientSession.swift b/Sources/MongoSwiftSync/ClientSession.swift index 4d0f94d1d..0edf31dee 100644 --- a/Sources/MongoSwiftSync/ClientSession.swift +++ b/Sources/MongoSwiftSync/ClientSession.swift @@ -48,6 +48,18 @@ public final class ClientSession { /// The options used to start this session. public var options: ClientSessionOptions? { self.asyncSession.options } + /// The session ID of this session. We only have a value available after we've started the libmongoc session. + public var id: Document? { self.asyncSession.id } + + /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. + public var serverId: Int? { self.asyncSession.serverId } + + /// Enum tracking the state of the transaction associated with this session. + public typealias TransactionState = MongoSwift.ClientSession.TransactionState + + /// The transaction state of this session. + public var transactionState: TransactionState? { self.asyncSession.transactionState } + /// Initializes a new client session. internal init(client: MongoClient, options: ClientSessionOptions?) { self.client = client diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 1e2f8274f..85d4a8171 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -366,6 +366,12 @@ extension SyncMongoClientTests { ] } +extension TransactionsTests { + static var allTests = [ + ("testTransactions", testTransactions), + ] +} + extension WriteConcernTests { static var allTests = [ ("testWriteConcernType", testWriteConcernType), @@ -408,5 +414,6 @@ XCTMain([ testCase(SyncChangeStreamTests.allTests), testCase(SyncClientSessionTests.allTests), testCase(SyncMongoClientTests.allTests), + testCase(TransactionsTests.allTests), testCase(WriteConcernTests.allTests), ]) diff --git a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift index 3f2e8310b..a87b32073 100644 --- a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift +++ b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift @@ -99,7 +99,10 @@ final class RetryableWritesTests: MongoSwiftTestCase, FailPointConfigured { var seenError: Error? do { - result = try test.operation.execute(on: .collection(collection), session: nil) + result = try test.operation.execute( + on: .collection(collection), + sessionDict: [String: ClientSession]() + ) } catch { if let bulkError = error as? BulkWriteError { result = TestOperationResult(from: bulkError.result) diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/FailPoint.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/FailPoint.swift index 00d7e3365..b25e539a0 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/FailPoint.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/FailPoint.swift @@ -102,6 +102,7 @@ internal struct FailPoint: Decodable { mode: Mode, closeConnection: Bool? = nil, errorCode: Int? = nil, + errorLabels: [String]? = nil, writeConcernError: Document? = nil ) -> FailPoint { var data: Document = [ @@ -113,6 +114,9 @@ internal struct FailPoint: Decodable { if let code = errorCode { data["errorCode"] = BSON(code) } + if let labels = errorLabels { + data["errorLabels"] = .array(labels.map { .string($0) }) + } if let writeConcernError = writeConcernError { data["writeConcernError"] = .document(writeConcernError) } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift index 986158846..a57ce1f06 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift @@ -69,8 +69,16 @@ extension Array: Matchable where Element: Matchable { extension Document: Matchable { internal func contentMatches(expected: Document) -> Bool { for (eK, eV) in expected { - guard let aV = self[eK], aV.matches(expected: eV) else { - return false + if eV != .null { + guard let aV = self[eK], aV.matches(expected: eV) else { + return false + } + } else { + // If the expected document has "key": null then the actual document must either have "key": null + // or no reference to "key". + if let aV = self[eK], aV.matches(expected: eV) { + return false + } } } return true diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index b763e8cd0..0fcc7ce5e 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -6,7 +6,7 @@ import XCTest /// A struct containing the portions of a `CommandStartedEvent` the spec tests use for testing. internal struct TestCommandStartedEvent: Decodable, Matchable { - let command: Document + var command: Document let commandName: String @@ -52,6 +52,12 @@ internal struct TestCommandStartedEvent: Decodable, Matchable { return self.commandName.matches(expected: expected.commandName) && self.command.matches(expected: expected.command) } + + internal mutating func excludeKeys(keys: [String]) throws { + var newCommand = Document() + try self.command.copyElements(to: &newCommand, excluding: keys) + self.command = newCommand + } } /// Struct representing conditions that a deployment must meet in order for a test file to be run. @@ -167,6 +173,10 @@ extension SpecTestFile { internal func populateData(using client: MongoClient) throws { let database = client.db(self.databaseName) + // Majority write concern ensures that initial data is propagated to all nodes in a replica set or sharded + // cluster. + let collectionOptions = CollectionOptions(writeConcern: try WriteConcern(w: .majority)) + try? database.drop() switch self.data { @@ -179,13 +189,13 @@ extension SpecTestFile { return } - try database.collection(collName).insertMany(docs) + try database.collection(collName, options: collectionOptions).insertMany(docs) case let .multiple(mapping): for (k, v) in mapping { guard !v.isEmpty else { continue } - try database.collection(k).insertMany(v) + try database.collection(k, options: collectionOptions).insertMany(v) } } } @@ -203,14 +213,13 @@ extension SpecTestFile { } } - try self.populateData(using: setupClient) - fileLevelLog("Executing tests from file \(self.name)...") for test in self.tests { guard skippedTestKeywords.allSatisfy({ !test.description.contains($0) }) else { print("Skipping test \(test.description)") return } + try self.populateData(using: setupClient) try test.run(parent: parent, dbName: self.databaseName, collName: self.collectionName) } } @@ -241,10 +250,21 @@ internal protocol SpecTest: Decodable { /// List of expected CommandStartedEvents. var expectations: [TestCommandStartedEvent]? { get } + + /// Document describing the return value and/or expected state of the collection after the operation is executed. + var outcome: TestOutcome? { get } + + /// Map of session names (e.g. "session0") to parameters to pass to `MongoClient.startSession()` when creating that + /// session. + var sessionOptions: [String: ClientSessionOptions]? { get } } /// Default implementation of a test execution. extension SpecTest { + var outcome: TestOutcome? { nil } + + var sessionOptions: [String: ClientSessionOptions]? { nil } + internal func run( parent: FailPointConfigured, dbName: String, @@ -259,35 +279,93 @@ extension SpecTest { let clientOptions = self.clientOptions ?? ClientOptions(retryReads: true) + let client = try MongoClient.makeTestClient(options: clientOptions) + let monitor = client.addCommandMonitor() + + if let collName = collName { + _ = try? client.db(dbName).createCollection(collName) + // Run the distinct command before every test to prevent `StableDbVersion` error in sharded cluster + // transactions. This workaround can be removed once SERVER-39704 is resolved. + _ = try? client.db(dbName).collection(collName).distinct(fieldName: "_id") + } + if let failPoint = self.failPoint { try parent.activateFailPoint(failPoint) } defer { parent.disableActiveFailPoint() } - let client = try MongoClient.makeTestClient(options: clientOptions) - let monitor = client.addCommandMonitor() - - let db = client.db(dbName) - var collection: MongoCollection? + // The spec tests refer to the sessions below. Proactively start them in case this spec test requires one. + let sessionsToStart = ["session0", "session1"] - if let collName = collName { - collection = db.collection(collName) + var sessionDict = [String: ClientSession]() + for session in sessionsToStart { + sessionDict[session] = client.startSession(options: self.sessionOptions?[session]) } + var sessionIds = [String: Document]() + try monitor.captureEvents { for operation in self.operations { try operation.validateExecution( client: client, - database: db, - collection: collection, - session: nil + dbName: dbName, + collName: collName, + sessionDict: sessionDict ) } + // Keep track of the session IDs assigned to each session. + // Deinitialize each session thereby implicitly ending them. + for session in sessionDict.keys { + sessionIds[session] = sessionDict[session]?.id + sessionDict[session] = nil + } + } + + let events = try monitor.commandStartedEvents().map { commandStartedEvent in + try processCommandStartedEvent(commandStartedEvent: commandStartedEvent, sessionIds: sessionIds) } - let events = monitor.commandStartedEvents().map { TestCommandStartedEvent(from: $0) } if let expectations = self.expectations { expect(events).to(match(expectations), description: self.description) } + + if let outcome = self.outcome { + try self.checkOutcome(outcome: outcome, dbName: dbName, collName: collName!) + } + } + + internal func processCommandStartedEvent( + commandStartedEvent: CommandStartedEvent, + sessionIds: [String: Document] + ) + throws -> TestCommandStartedEvent { + var event = TestCommandStartedEvent(from: commandStartedEvent) + try event.excludeKeys(keys: ["$db", "$clusterTime"]) + + // If command started event has "lsid": Document(...), change the value to correpond to "session0", + // "session1", etc. + if let sessionDoc = event.command["lsid"]?.documentValue { + for session in sessionIds.keys { + if let sessionId = sessionIds[session], sessionId == sessionDoc { + event.command["lsid"] = .string(session) + } + } + } + // If command is "findAndModify" and does not have key "new", add the default value "new": false. + if event.commandName == "findAndModify" && event.command["new"] == nil { + event.command["new"] = .bool(false) + } + + return event + } + + internal func checkOutcome(outcome: TestOutcome, dbName: String, collName: String) throws { + let client = try MongoClient.makeTestClient() + let verifyColl = client.db(dbName).collection(collName) + let foundDocs = try Array(verifyColl.find().all()) + expect(foundDocs.count).to(equal(outcome.collection.data.count)) + zip(foundDocs, outcome.collection.data).forEach { + expect($0).to(sortedEqual($1), description: self.description) + } } } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift index ece691db4..b9673ed1a 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift @@ -4,7 +4,7 @@ import TestsCommon /// A enumeration of the different objects a `TestOperation` may be performed against. enum TestOperationObject: String, Decodable { - case client, database, collection, gridfsbucket + case client, database, collection, gridfsbucket, session0, session1, testRunner } /// Struct containing an operation and an expected outcome. @@ -21,8 +21,17 @@ struct TestOperationDescription: Decodable { /// Whether the operation should expect an error. let error: Bool? - public enum CodingKeys: CodingKey { - case object, result, error + /// The parameters to pass to the database used for this operation. + let databaseOptions: DatabaseOptions? + + /// The parameters to pass to the collection used for this operation. + let collectionOptions: CollectionOptions? + + /// Present only when the operation is `runCommand`. The name of the command to run. + let commandName: String? + + public enum CodingKeys: String, CodingKey { + case object, result, error, databaseOptions, collectionOptions, commandName = "command_name" } public init(from decoder: Decoder) throws { @@ -32,23 +41,32 @@ struct TestOperationDescription: Decodable { self.object = try container.decode(TestOperationObject.self, forKey: .object) self.result = try container.decodeIfPresent(TestOperationResult.self, forKey: .result) self.error = try container.decodeIfPresent(Bool.self, forKey: .error) + self.databaseOptions = try container.decodeIfPresent(DatabaseOptions.self, forKey: .databaseOptions) + self.collectionOptions = try container.decodeIfPresent(CollectionOptions.self, forKey: .collectionOptions) + self.commandName = try container.decodeIfPresent(String.self, forKey: .commandName) } + // swiftlint:disable cyclomatic_complexity + /// Runs the operation and asserts its results meet the expectation. func validateExecution( client: MongoClient, - database: MongoDatabase?, - collection: MongoCollection?, - session: ClientSession? + dbName: String, + collName: String?, + sessionDict: [String: ClientSession] ) throws { + let database = client.db(dbName, options: self.databaseOptions) + var collection: MongoCollection? + + if let collName = collName { + collection = database.collection(collName, options: self.collectionOptions) + } + let target: TestOperationTarget switch self.object { case .client: target = .client(client) case .database: - guard let database = database else { - throw TestError(message: "got database object but was not provided a database") - } target = .database(database) case .collection: guard let collection = collection else { @@ -57,19 +75,138 @@ struct TestOperationDescription: Decodable { target = .collection(collection) case .gridfsbucket: throw TestError(message: "gridfs tests should be skipped") + case .session0: + guard let session0 = sessionDict["session0"] else { + throw TestError(message: "got session0 object but was not provided a session") + } + target = .session(session0) + case .session1: + guard let session1 = sessionDict["session1"] else { + throw TestError(message: "got session1 object but was not provided a session") + } + target = .session(session1) + case .testRunner: + target = .testRunner(database) } do { - let result = try self.operation.execute(on: target, session: session) + let result = try self.operation.execute(on: target, sessionDict: sessionDict) expect(self.error ?? false) .to(beFalse(), description: "expected to fail but succeeded with result \(String(describing: result))") if let expectedResult = self.result { expect(result).to(equal(expectedResult)) } + } catch let error as CommandError { + try checkCommandError(error: error) + } catch let error as WriteError { + try checkWriteError(error: error) + } catch let error as BulkWriteError { + try checkBulkWriteError(error: error) + } catch let error as LogicError { + try checkLogicError(error: error) + } catch let error as InvalidArgumentError { + try checkInvalidArgumentError(error: error) + } catch let error as ConnectionError { + try checkConnectionError(error: error) } catch { expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") } } + + public func checkErrorContains(error: Error, errorDescription: String) throws { + if case let .document(expectedResult) = self.result { + if let errorContains = expectedResult["errorContains"]?.stringValue { + expect(errorDescription.lowercased()).to(contain(errorContains.lowercased())) + } + } else { + expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") + } + } + + public func checkCodeName(error: Error, codeName: String) throws { + if case let .document(expectedResult) = self.result { + if let errorCodeName = expectedResult["errorCodeName"]?.stringValue, !codeName.isEmpty { + expect(codeName).to(equal(errorCodeName)) + } + } else { + expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") + } + } + + public func checkErrorLabels(error: Error, errorLabels: [String]?) throws { + // `configureFailPoint` command correctly handles error labels in MongoDB v4.3.1+ (see SERVER-43941). + // Do not check the "RetryableWriteError" error label until the spec test requirements are updated. + let skippedErrorLabels = ["RetryableWriteError"] + + if case let .document(expectedResult) = self.result { + if let errorLabelsContain = expectedResult["errorLabelsContain"]?.arrayValue, + let errorLabels = errorLabels { + errorLabelsContain.forEach { label in + if let label = label.stringValue, !skippedErrorLabels.contains(label) { + expect(errorLabels).to(contain(label)) + } + } + } + if let errorLabelsOmit = expectedResult["errorLabelsOmit"]?.arrayValue, + let errorLabels = errorLabels { + errorLabelsOmit.forEach { label in + if let label = label.stringValue { + expect(errorLabels).toNot(contain(label)) + } + } + } + } else { + expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") + } + } + + public func checkCommandError(error: CommandError) throws { + try self.checkErrorContains(error: error, errorDescription: error.message) + try self.checkCodeName(error: error, codeName: error.codeName) + try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) + } + + public func checkWriteError(error: WriteError) throws { + if let writeFailure = error.writeFailure { + try self.checkErrorContains(error: error, errorDescription: writeFailure.message) + try self.checkCodeName(error: error, codeName: writeFailure.codeName) + } + if let writeConcernFailure = error.writeConcernFailure { + try self.checkErrorContains(error: error, errorDescription: writeConcernFailure.message) + try self.checkCodeName(error: error, codeName: writeConcernFailure.codeName) + } + try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) + } + + public func checkBulkWriteError(error: BulkWriteError) throws { + if let writeFailures = error.writeFailures { + try writeFailures.forEach { writeFailure in + try checkErrorContains(error: error, errorDescription: writeFailure.message) + try checkCodeName(error: error, codeName: writeFailure.codeName) + } + } + if let writeConcernFailure = error.writeConcernFailure { + try self.checkErrorContains(error: error, errorDescription: writeConcernFailure.message) + try self.checkCodeName(error: error, codeName: writeConcernFailure.codeName) + } + try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) + } + + public func checkLogicError(error: LogicError) throws { + try self.checkErrorContains(error: error, errorDescription: error.errorDescription) + // `LogicError` does not have error labels or a code name so there is no need to check them. + } + + public func checkInvalidArgumentError(error: InvalidArgumentError) throws { + try self.checkErrorContains(error: error, errorDescription: error.errorDescription) + // `InvalidArgumentError` does not have error labels or a code name so there is no need to check them. + } + + public func checkConnectionError(error: ConnectionError) throws { + try self.checkErrorContains(error: error, errorDescription: error.message) + try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) + // `ConnectionError` does not have a code name so there is no need to check it. + } } /// Object in which an operation should be executed on. @@ -83,12 +220,18 @@ enum TestOperationTarget { /// Execute against the provided collection. case collection(MongoCollection) + + /// Execute against the provided session. + case session(ClientSession) + + /// Execute against the provided test runner. + case testRunner(MongoDatabase) } /// Protocol describing the behavior of a spec test "operation" protocol TestOperation: Decodable { /// Execute the operation given the context. - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? } /// Wrapper around a `TestOperation.swift` allowing it to be decoded from a spec test. @@ -141,8 +284,32 @@ struct AnyTestOperation: Decodable, TestOperation { self.op = try container.decode(ReplaceOne.self, forKey: .arguments) case "rename": self.op = try container.decode(RenameCollection.self, forKey: .arguments) + case "startTransaction": + self.op = (try? container.decode(StartTransaction.self, forKey: .arguments)) ?? StartTransaction() + case "createCollection": + self.op = try container.decode(CreateCollection.self, forKey: .arguments) + case "dropCollection": + self.op = try container.decode(DropCollection.self, forKey: .arguments) + case "createIndex": + self.op = try container.decode(CreateIndex.self, forKey: .arguments) + case "runCommand": + self.op = try container.decode(RunCommand.self, forKey: .arguments) + case "assertCollectionExists": + self.op = try container.decode(AssertCollectionExists.self, forKey: .arguments) + case "assertCollectionNotExists": + self.op = try container.decode(AssertCollectionNotExists.self, forKey: .arguments) + case "assertIndexExists": + self.op = try container.decode(AssertIndexExists.self, forKey: .arguments) + case "assertIndexNotExists": + self.op = try container.decode(AssertIndexNotExists.self, forKey: .arguments) + case "assertSessionPinned": + self.op = try container.decode(AssertSessionPinned.self, forKey: .arguments) + case "assertSessionUnpinned": + self.op = try container.decode(AssertSessionUnpinned.self, forKey: .arguments) + case "assertSessionTransactionState": + self.op = try container.decode(AssertSessionTransactionState.self, forKey: .arguments) case "drop": - self.op = DropCollection() + self.op = Drop() case "listDatabaseNames": self.op = ListDatabaseNames() case "listDatabases": @@ -161,75 +328,86 @@ struct AnyTestOperation: Decodable, TestOperation { self.op = ListCollectionNames() case "watch": self.op = Watch() - case "mapReduce", "download_by_name", "download", "count": + case "commitTransaction": + self.op = CommitTransaction() + case "abortTransaction": + self.op = AbortTransaction() + case "mapReduce", "download_by_name", "download", "count", "targetedFailPoint": self.op = NotImplemented(name: opName) default: throw TestError(message: "unsupported op name \(opName)") } } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { - try self.op.execute(on: target, session: session) + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + try self.op.execute(on: target, sessionDict: sessionDict) } } struct Aggregate: TestOperation { + let session: String let pipeline: [Document] let options: AggregateOptions - private enum CodingKeys: String, CodingKey { case pipeline } + private enum CodingKeys: String, CodingKey { case session, pipeline } init(from decoder: Decoder) throws { self.options = try AggregateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.pipeline = try container.decode([Document].self, forKey: .pipeline) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to aggregate") } return try TestOperationResult( - from: collection.aggregate(self.pipeline, options: self.options, session: session) + from: collection.aggregate(self.pipeline, options: self.options, session: sessionDict[self.session]) ) } } struct CountDocuments: TestOperation { + let session: String let filter: Document let options: CountDocumentsOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try CountDocumentsOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to count") } - return .int(try collection.countDocuments(self.filter, options: self.options, session: session)) + return .int( + try collection.countDocuments(self.filter, options: self.options, session: sessionDict[self.session])) } } struct Distinct: TestOperation { + let session: String let fieldName: String let filter: Document? let options: DistinctOptions - private enum CodingKeys: String, CodingKey { case fieldName, filter } + private enum CodingKeys: String, CodingKey { case session, fieldName, filter } init(from decoder: Decoder) throws { self.options = try DistinctOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.fieldName = try container.decode(String.self, forKey: .fieldName) self.filter = try container.decodeIfPresent(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to distinct") } @@ -237,67 +415,77 @@ struct Distinct: TestOperation { fieldName: self.fieldName, filter: self.filter ?? [:], options: self.options, - session: session + session: sessionDict[self.session] ) return .array(result) } } struct Find: TestOperation { + let session: String let filter: Document let options: FindOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try FindOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.filter = try container.decode(Document.self, forKey: .filter) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.filter = (try? container.decode(Document.self, forKey: .filter)) ?? Document() } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to find") } - return try TestOperationResult(from: collection.find(self.filter, options: self.options, session: session)) + return try TestOperationResult( + from: collection.find(self.filter, options: self.options, session: sessionDict[self.session]) + ) } } struct FindOne: TestOperation { + let session: String let filter: Document let options: FindOneOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try FindOneOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOne") } - return try TestOperationResult(from: collection.findOne(self.filter, options: self.options, session: session)) + return try TestOperationResult( + from: collection.findOne(self.filter, options: self.options, session: sessionDict[self.session]) + ) } } struct UpdateOne: TestOperation { + let session: String let filter: Document let update: Document let options: UpdateOptions - private enum CodingKeys: String, CodingKey { case filter, update } + private enum CodingKeys: String, CodingKey { case session, filter, update } init(from decoder: Decoder) throws { self.options = try UpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to updateOne") } @@ -306,27 +494,29 @@ struct UpdateOne: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: session + session: sessionDict[self.session] ) return TestOperationResult(from: result) } } struct UpdateMany: TestOperation { + let session: String let filter: Document let update: Document let options: UpdateOptions - private enum CodingKeys: String, CodingKey { case filter, update } + private enum CodingKeys: String, CodingKey { case session, filter, update } init(from decoder: Decoder) throws { self.options = try UpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to ") } @@ -335,74 +525,101 @@ struct UpdateMany: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: session + session: sessionDict[self.session] ) return TestOperationResult(from: result) } } struct DeleteMany: TestOperation { + let session: String let filter: Document let options: DeleteOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try DeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to deleteMany") } - let result = try collection.deleteMany(self.filter, options: self.options, session: session) + let result = try collection.deleteMany(self.filter, options: self.options, session: sessionDict[self.session]) return TestOperationResult(from: result) } } struct DeleteOne: TestOperation { + let session: String let filter: Document let options: DeleteOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try DeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to deleteOne") } - let result = try collection.deleteOne(self.filter, options: self.options, session: session) + let result = try collection.deleteOne(self.filter, options: self.options, session: sessionDict[self.session]) return TestOperationResult(from: result) } } struct InsertOne: TestOperation { + let session: String let document: Document - func execute(on target: TestOperationTarget, session _: ClientSession?) throws -> TestOperationResult? { + private enum CodingKeys: String, CodingKey { case session, document } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.document = try container.decode(Document.self, forKey: .document) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to insertOne") } - return TestOperationResult(from: try collection.insertOne(self.document)) + return TestOperationResult(from: try collection.insertOne(self.document, session: sessionDict[self.session])) } } struct InsertMany: TestOperation { + let session: String let documents: [Document] let options: InsertManyOptions - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + private enum CodingKeys: String, CodingKey { case session, documents } + + init(from decoder: Decoder) throws { + self.options = (try? InsertManyOptions(from: decoder)) ?? InsertManyOptions() + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.documents = try container.decode([Document].self, forKey: .documents) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to insertMany") } - let result = try collection.insertMany(self.documents, options: self.options, session: session) + let result = try collection.insertMany( + self.documents, + options: self.options, + session: sessionDict[self.session] + ) return TestOperationResult(from: result) } } @@ -414,19 +631,19 @@ extension WriteModel: Decodable { } private enum InsertOneKeys: CodingKey { - case document + case session, document } private enum DeleteKeys: CodingKey { - case filter + case session, filter } private enum ReplaceOneKeys: CodingKey { - case filter, replacement + case session, filter, replacement } private enum UpdateKeys: CodingKey { - case filter, update + case session, filter, update } public init(from decoder: Decoder) throws { @@ -470,33 +687,45 @@ extension WriteModel: Decodable { } struct BulkWrite: TestOperation { + let session: String let requests: [WriteModel] let options: BulkWriteOptions - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + private enum CodingKeys: CodingKey { case session, requests } + + init(from decoder: Decoder) throws { + self.options = (try? BulkWriteOptions(from: decoder)) ?? BulkWriteOptions() + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.requests = try container.decode([WriteModel].self, forKey: .requests) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to bulk write") } - let result = try collection.bulkWrite(self.requests, options: self.options, session: session) + let result = try collection.bulkWrite(self.requests, options: self.options, session: sessionDict[self.session]) return TestOperationResult(from: result) } } struct FindOneAndUpdate: TestOperation { + let session: String let filter: Document let update: Document let options: FindOneAndUpdateOptions - private enum CodingKeys: String, CodingKey { case filter, update } + private enum CodingKeys: String, CodingKey { case session, filter, update } init(from decoder: Decoder) throws { self.options = try FindOneAndUpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndUpdate") } @@ -504,48 +733,56 @@ struct FindOneAndUpdate: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: session + session: sessionDict[self.session] ) return TestOperationResult(from: doc) } } struct FindOneAndDelete: TestOperation { + let session: String let filter: Document let options: FindOneAndDeleteOptions - private enum CodingKeys: String, CodingKey { case filter } + private enum CodingKeys: String, CodingKey { case session, filter } init(from decoder: Decoder) throws { self.options = try FindOneAndDeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndDelete") } - let result = try collection.findOneAndDelete(self.filter, options: self.options, session: session) + let result = try collection.findOneAndDelete( + self.filter, + options: self.options, + session: sessionDict[self.session] + ) return TestOperationResult(from: result) } } struct FindOneAndReplace: TestOperation { + let session: String let filter: Document let replacement: Document let options: FindOneAndReplaceOptions - private enum CodingKeys: String, CodingKey { case filter, replacement } + private enum CodingKeys: String, CodingKey { case session, filter, replacement } init(from decoder: Decoder) throws { self.options = try FindOneAndReplaceOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) self.replacement = try container.decode(Document.self, forKey: .replacement) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndReplace") } @@ -553,27 +790,29 @@ struct FindOneAndReplace: TestOperation { filter: self.filter, replacement: self.replacement, options: self.options, - session: session + session: sessionDict[self.session] ) return TestOperationResult(from: result) } } struct ReplaceOne: TestOperation { + let session: String let filter: Document let replacement: Document let options: ReplaceOptions - private enum CodingKeys: String, CodingKey { case filter, replacement } + private enum CodingKeys: String, CodingKey { case session, filter, replacement } init(from decoder: Decoder) throws { self.options = try ReplaceOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" self.filter = try container.decode(Document.self, forKey: .filter) self.replacement = try container.decode(Document.self, forKey: .replacement) } - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to replaceOne") } @@ -581,15 +820,24 @@ struct ReplaceOne: TestOperation { filter: self.filter, replacement: self.replacement, options: self.options, - session: session + session: sessionDict[self.session] )) } } struct RenameCollection: TestOperation { + let session: String let to: String - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + private enum CodingKeys: String, CodingKey { case session, to } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.to = try container.decode(String.self, forKey: .to) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to renameCollection") } @@ -599,14 +847,17 @@ struct RenameCollection: TestOperation { "renameCollection": .string(databaseName + "." + collection.name), "to": .string(databaseName + "." + self.to) ] - return try TestOperationResult(from: collection._client.db("admin").runCommand(cmd, session: session)) + return try TestOperationResult( + from: collection._client.db("admin").runCommand(cmd, session: sessionDict[self.session]) + ) } } -struct DropCollection: TestOperation { - func execute(on target: TestOperationTarget, session _: ClientSession?) throws -> TestOperationResult? { +struct Drop: TestOperation { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .collection(collection) = target else { - throw TestError(message: "collection not provided to dropCollection") + throw TestError(message: "collection not provided to drop") } try collection.drop() return nil @@ -614,99 +865,406 @@ struct DropCollection: TestOperation { } struct ListDatabaseNames: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabaseNames") } - return try .array(client.listDatabaseNames(session: session).map { .string($0) }) + return try .array(client.listDatabaseNames(session: nil).map { .string($0) }) } } struct ListIndexes: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexes") } - return try TestOperationResult(from: collection.listIndexes(session: session)) + return try TestOperationResult(from: collection.listIndexes(session: nil)) } } struct ListIndexNames: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexNames") } - return try .array(collection.listIndexNames(session: session).map { .string($0) }) + return try .array(collection.listIndexNames(session: nil).map { .string($0) }) } } struct ListDatabases: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") } - return try TestOperationResult(from: client.listDatabases(session: session)) + return try TestOperationResult(from: client.listDatabases(session: nil)) } } struct ListMongoDatabases: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") } - _ = try client.listMongoDatabases(session: session) + _ = try client.listMongoDatabases(session: nil) return nil } } struct ListCollections: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollections") } - return try TestOperationResult(from: database.listCollections(session: session)) + return try TestOperationResult(from: database.listCollections(session: nil)) } } struct ListMongoCollections: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionObjects") } - _ = try database.listMongoCollections(session: session) + _ = try database.listMongoCollections(session: nil) return nil } } struct ListCollectionNames: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionNames") } - return try .array(database.listCollectionNames(session: session).map { .string($0) }) + return try .array(database.listCollectionNames(session: nil).map { .string($0) }) } } struct Watch: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { switch target { case let .client(client): - _ = try client.watch(session: session) + _ = try client.watch(session: nil) case let .database(database): - _ = try database.watch(session: session) + _ = try database.watch(session: nil) case let .collection(collection): - _ = try collection.watch(session: session) + _ = try collection.watch(session: nil) + case .session, .testRunner: + break } return nil } } struct EstimatedDocumentCount: TestOperation { - func execute(on target: TestOperationTarget, session: ClientSession?) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to estimatedDocumentCount") } - return try .int(collection.estimatedDocumentCount(session: session)) + return try .int(collection.estimatedDocumentCount(session: nil)) + } +} + +struct StartTransaction: TestOperation { + let options: TransactionOptions + + private enum CodingKeys: CodingKey { + case options + } + + init() { + self.options = TransactionOptions() + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.options = try container.decode(TransactionOptions.self, forKey: .options) + } + + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .session(session) = target else { + throw TestError(message: "session not provided to startTransaction") + } + _ = try session.startTransaction(options: self.options) + return nil + } +} + +struct CommitTransaction: TestOperation { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .session(session) = target else { + throw TestError(message: "session not provided to commitTransaction") + } + _ = try session.commitTransaction() + return nil + } +} + +struct AbortTransaction: TestOperation { + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .session(session) = target else { + throw TestError(message: "session not provided to abortTransaction") + } + _ = try session.abortTransaction() + return nil + } +} + +struct CreateCollection: TestOperation { + let session: String + let collection: String + + private enum CodingKeys: String, CodingKey { case session, collection } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.collection = try container.decode(String.self, forKey: .collection) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard case let .database(database) = target else { + throw TestError(message: "database not provided to createCollection") + } + _ = try database.createCollection(self.collection, session: sessionDict[self.session]) + return nil + } +} + +struct DropCollection: TestOperation { + let session: String + let collection: String + + private enum CodingKeys: String, CodingKey { case session, collection } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.collection = try container.decode(String.self, forKey: .collection) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard case let .database(database) = target else { + throw TestError(message: "database not provided to dropCollection") + } + _ = try database.collection(self.collection).drop(session: sessionDict[self.session]) + return nil + } +} + +struct CreateIndex: TestOperation { + let session: String + let name: String + let keys: Document + + private enum CodingKeys: String, CodingKey { case session, name, keys } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.name = try container.decode(String.self, forKey: .name) + self.keys = try container.decode(Document.self, forKey: .keys) + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard case let .collection(collection) = target else { + throw TestError(message: "collection not provided to createIndex") + } + let indexOptions = IndexOptions(name: self.name) + _ = try collection.createIndex(self.keys, indexOptions: indexOptions, session: sessionDict[self.session]) + return nil + } +} + +struct RunCommand: TestOperation { + let session: String + let command: Document + let readPreference: ReadPreference + + private enum CodingKeys: String, CodingKey { case session, command, readPreference } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.command = try container.decode(Document.self, forKey: .command) + self.readPreference = (try? container.decode(ReadPreference.self, forKey: .readPreference)) ?? + ReadPreference.primary + } + + func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard case let .database(database) = target else { + throw TestError(message: "database not provided to runCommand") + } + let runCommandOptions = RunCommandOptions(readPreference: self.readPreference) + let result = try database.runCommand( + self.command, + options: runCommandOptions, + session: sessionDict[self.session] + ) + + var refinedResult = Document() + try result.copyElements( + to: &refinedResult, + excluding: [ + "opTime", "electionId", "ok", "$clusterTime", "signature", "keyId", "operationTime", + "recoveryToken" + ] + ) + return TestOperationResult(from: refinedResult) + } +} + +struct AssertCollectionExists: TestOperation { + let database: String + let collection: String + + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .testRunner(database) = target else { + throw TestError(message: "database not provided to assertCollectionExists") + } + let client = try MongoClient.makeTestClient() + let collectionNames = try client.db(database.name).listCollectionNames(session: nil) + guard collectionNames.contains(self.collection) else { + throw TestError(message: "expected \(database).\(self.collection) to exist, but it does not") + } + return nil + } +} + +struct AssertCollectionNotExists: TestOperation { + let database: String + let collection: String + + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .testRunner(database) = target else { + throw TestError(message: "database not provided to assertCollectionNotExists") + } + let client = try MongoClient.makeTestClient() + let collectionNames = try client.db(database.name).listCollectionNames(session: nil) + guard !collectionNames.contains(self.collection) else { + throw TestError(message: "expected \(database).\(self.collection) to not exist, but it does") + } + return nil + } +} + +struct AssertIndexExists: TestOperation { + let database: String + let collection: String + let index: String + + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .testRunner(database) = target else { + throw TestError(message: "database not provided to assertIndexExists") + } + let client = try MongoClient.makeTestClient() + let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) + guard indexNames.contains(self.index) else { + throw TestError( + message: "expected \(self.index) to exist in \(database).\(self.collection), but it does not" + ) + } + return nil + } +} + +struct AssertIndexNotExists: TestOperation { + let database: String + let collection: String + let index: String + + func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + throws -> TestOperationResult? { + guard case let .testRunner(database) = target else { + throw TestError(message: "database not provided to assertIndexNotExists") + } + let client = try MongoClient.makeTestClient() + let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) + guard !indexNames.contains(self.index) else { + throw TestError( + message: "expected \(self.index) to not exist in \(database).\(self.collection), but it does" + ) + } + return nil + } +} + +struct AssertSessionPinned: TestOperation { + let session: String + + private enum CodingKeys: String, CodingKey { case session } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + } + + func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard let serverId = sessionDict[self.session]?.serverId else { + throw TestError(message: "active session not provided to assertSessionPinned") + } + guard serverId != 0 else { + throw TestError(message: "expected session to be pinned, got unpinned") + } + return nil + } +} + +struct AssertSessionUnpinned: TestOperation { + let session: String + + private enum CodingKeys: String, CodingKey { case session } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + } + + func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard let serverId = sessionDict[self.session]?.serverId else { + throw TestError(message: "active session not provided to assertSessionPinned") + } + guard serverId == 0 else { + throw TestError(message: "expected session to be pinned, got unpinned") + } + return nil + } +} + +struct AssertSessionTransactionState: TestOperation { + let session: String + let state: ClientSession.TransactionState + + private enum CodingKeys: String, CodingKey { case session, state } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.state = try container.decode(ClientSession.TransactionState.self, forKey: .state) + } + + func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + guard let transactionState = sessionDict[self.session]?.transactionState else { + throw TestError(message: "active session not provided to assertSessionTransactionState") + } + guard self.state == transactionState else { + throw TestError(message: "expected transaction state to be \(self.state), got \(transactionState)") + } + return nil } } @@ -714,7 +1272,7 @@ struct EstimatedDocumentCount: TestOperation { struct NotImplemented: TestOperation { internal let name: String - func execute(on _: TestOperationTarget, session _: ClientSession?) throws -> TestOperationResult? { + func execute(on _: TestOperationTarget, sessionDict _: [String: ClientSession]) throws -> TestOperationResult? { throw TestError(message: "\(self.name) not implemented in the driver, skip this test") } } diff --git a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift index 1916d161e..d7caefb31 100644 --- a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift +++ b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift @@ -69,7 +69,7 @@ internal struct ChangeStreamTestOperation: Decodable { internal func execute(using client: MongoClient) throws -> TestOperationResult? { let db = client.db(self.database) let coll = db.collection(self.collection) - return try self.operation.execute(on: .collection(coll), session: nil) + return try self.operation.execute(on: .collection(coll), sessionDict: [String: ClientSession]()) } } diff --git a/Tests/MongoSwiftSyncTests/TransactionsTests.swift b/Tests/MongoSwiftSyncTests/TransactionsTests.swift new file mode 100644 index 000000000..e147dd1cb --- /dev/null +++ b/Tests/MongoSwiftSyncTests/TransactionsTests.swift @@ -0,0 +1,139 @@ +import Foundation +import MongoSwift +import Nimble +import TestsCommon + +/// Struct representing a single test within a spec test JSON file. +private struct TransactionsTest: SpecTest { + let description: String + + let operations: [TestOperationDescription] + + let outcome: TestOutcome? + + let skipReason: String? + + let useMultipleMongoses: Bool? + + let clientOptions: ClientOptions? + + let failPoint: FailPoint? + + let sessionOptions: [String: ClientSessionOptions]? + + let expectations: [TestCommandStartedEvent]? +} + +/// Struct representing a single transactions spec test JSON file. +private struct TransactionsTestFile: Decodable, SpecTestFile { + private enum CodingKeys: String, CodingKey { + case name, runOn, databaseName = "database_name", collectionName = "collection_name", data, tests + } + + let name: String + + let runOn: [TestRequirement]? + + let databaseName: String + + let collectionName: String? + + let data: TestData + + let tests: [TransactionsTest] +} + +final class TransactionsTests: MongoSwiftTestCase, FailPointConfigured { + var activeFailPoint: FailPoint? + + override func tearDown() { + self.disableActiveFailPoint() + } + + override func setUp() { + self.continueAfterFailure = false + } + + func testTransactions() throws { + let skippedTestKeywords = [ + "count", // skipped in RetryableReadsTests.swift + "mongos-pin-auto", // useMultipleMongoses, targetedFailPoint not implemented + "mongos-recovery-token", // useMultipleMongoses, targetedFailPoint not implemented + "pin-mongos", // useMultipleMongoses, targetedFailPoint not implemented + "retryable-abort-errorLabels", // requires libmongoc v1.17 (see CDRIVER-3462) + "retryable-commit-errorLabels" // requires libmongoc v1.17 (see CDRIVER-3462) + ] + + let tests = try retrieveSpecTestFiles(specName: "transactions", asType: TransactionsTestFile.self) + for (_, testFile) in tests { + guard skippedTestKeywords.allSatisfy({ !testFile.name.contains($0) }) else { + fileLevelLog("Skipping tests from file \(testFile.name)...") + continue + } + try testFile.runTests(parent: self) + } + } +} + +extension DatabaseOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init(readConcern: readConcern, readPreference: readPreference, writeConcern: writeConcern) + } + + private enum CodingKeys: CodingKey { + case readConcern, readPreference, writeConcern + } +} + +extension CollectionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init(readConcern: readConcern, writeConcern: writeConcern) + } + + private enum CodingKeys: CodingKey { + case readConcern, writeConcern + } +} + +extension ClientSessionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let causalConsistency = try? container.decode(Bool.self, forKey: .causalConsistency) + let defaultTransactionOptions = try? container.decode( + TransactionOptions.self, + forKey: .defaultTransactionOptions + ) + self.init(causalConsistency: causalConsistency, defaultTransactionOptions: defaultTransactionOptions) + } + + private enum CodingKeys: CodingKey { + case causalConsistency, defaultTransactionOptions + } +} + +extension TransactionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let maxCommitTimeMS = try? container.decode(Int64.self, forKey: .maxCommitTimeMS) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init( + maxCommitTimeMS: maxCommitTimeMS, + readConcern: readConcern, + readPreference: readPreference, + writeConcern: writeConcern + ) + } + + private enum CodingKeys: CodingKey { + case maxCommitTimeMS, readConcern, readPreference, writeConcern + } +} diff --git a/etc/add_json_files.rb b/etc/add_json_files.rb index a4d4c0ea8..e0745a7fc 100644 --- a/etc/add_json_files.rb +++ b/etc/add_json_files.rb @@ -20,6 +20,7 @@ def make_reference(project, path) change_streams = make_reference(project, "./Tests/Specs/change-streams") dns_seedlist = make_reference(project, "./Tests/Specs/initial-dns-seedlist-discovery") auth = make_reference(project, "./Tests/Specs/auth") -mongoswift_tests_target.add_resources([crud, corpus, cm, read_write_concern, retryable_writes, retryable_reads, change_streams, dns_seedlist, auth]) +transactions = make_reference(project, "./Tests/Specs/transactions") +mongoswift_tests_target.add_resources([crud, corpus, cm, read_write_concern, retryable_writes, retryable_reads, change_streams, dns_seedlist, auth, transactions]) project.save From 09141ce78c950f043718232a2ac6be79653f4ac0 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Mon, 6 Apr 2020 15:22:11 -0400 Subject: [PATCH 02/12] Small updates to transactions API and tests --- Sources/MongoSwift/ClientSession.swift | 2 +- Sources/MongoSwiftSync/ClientSession.swift | 2 +- Tests/MongoSwiftSyncTests/TransactionsTests.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index dff3bb77d..5851e48df 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -308,7 +308,7 @@ public final class ClientSession { * - SeeAlso: * - https://docs.mongodb.com/manual/core/transactions/ */ - public func startTransaction(_ options: TransactionOptions?) -> EventLoopFuture { + public func startTransaction(options: TransactionOptions? = nil) -> EventLoopFuture { switch self.state { case .notStarted, .started: let operation = StartTransactionOperation(options: options) diff --git a/Sources/MongoSwiftSync/ClientSession.swift b/Sources/MongoSwiftSync/ClientSession.swift index 0edf31dee..f061aa817 100644 --- a/Sources/MongoSwiftSync/ClientSession.swift +++ b/Sources/MongoSwiftSync/ClientSession.swift @@ -127,7 +127,7 @@ public final class ClientSession { * - https://docs.mongodb.com/manual/core/transactions/ */ public func startTransaction(options: TransactionOptions? = nil) throws { - try self.asyncSession.startTransaction(options).wait() + try self.asyncSession.startTransaction(options: options).wait() } /** diff --git a/Tests/MongoSwiftSyncTests/TransactionsTests.swift b/Tests/MongoSwiftSyncTests/TransactionsTests.swift index e147dd1cb..4d6bbc5cf 100644 --- a/Tests/MongoSwiftSyncTests/TransactionsTests.swift +++ b/Tests/MongoSwiftSyncTests/TransactionsTests.swift @@ -60,8 +60,8 @@ final class TransactionsTests: MongoSwiftTestCase, FailPointConfigured { "mongos-pin-auto", // useMultipleMongoses, targetedFailPoint not implemented "mongos-recovery-token", // useMultipleMongoses, targetedFailPoint not implemented "pin-mongos", // useMultipleMongoses, targetedFailPoint not implemented - "retryable-abort-errorLabels", // requires libmongoc v1.17 (see CDRIVER-3462) - "retryable-commit-errorLabels" // requires libmongoc v1.17 (see CDRIVER-3462) + "retryable-abort-errorLabels", // requires libmongoc v1.17 (see SWIFT-762) + "retryable-commit-errorLabels" // requires libmongoc v1.17 (see SWIFT-762) ] let tests = try retrieveSpecTestFiles(specName: "transactions", asType: TransactionsTestFile.self) From 1e89ecc16f84e56da199612d4735151b90c53f1f Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Tue, 7 Apr 2020 14:49:19 -0400 Subject: [PATCH 03/12] Responded to comments --- Sources/MongoSwift/BSON/Document.swift | 2 +- Sources/MongoSwift/ClientSession.swift | 8 +- Sources/MongoSwift/MongoClient.swift | 15 +- .../MongoCollection+BulkWrite.swift | 3 + Sources/MongoSwift/MongoError.swift | 28 +- Sources/MongoSwiftSync/ClientSession.swift | 12 - .../ClientSessionTests.swift | 14 +- .../CommandMonitoringTests.swift | 25 - .../RetryableReadsTests.swift | 2 +- .../RetryableWritesTests.swift | 2 +- .../SpecTestRunner/CodableExtensions.swift | 64 +++ .../SpecTestRunner/Match.swift | 17 +- .../SpecTestRunner/SpecTest.swift | 117 +++-- .../SpecTestRunner/TestOperation.swift | 453 +++++++----------- .../SpecTestRunner/TestOperationResult.swift | 167 ++++++- .../SyncChangeStreamTests.swift | 2 +- .../TransactionsTests.swift | 67 +-- 17 files changed, 517 insertions(+), 481 deletions(-) create mode 100644 Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift diff --git a/Sources/MongoSwift/BSON/Document.swift b/Sources/MongoSwift/BSON/Document.swift index 91d4b8969..39dd07529 100644 --- a/Sources/MongoSwift/BSON/Document.swift +++ b/Sources/MongoSwift/BSON/Document.swift @@ -258,7 +258,7 @@ extension Document { /// Helper function for copying elements from some source document to a destination document while /// excluding a non-zero number of keys - public func copyElements(to otherDoc: inout Document, excluding keys: [String]) throws { + internal func copyElements(to otherDoc: inout Document, excluding keys: [String]) throws { guard !keys.isEmpty else { throw InternalError(message: "No keys to exclude, use 'bson_copy' instead") } diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index 5851e48df..535997eb3 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -67,10 +67,10 @@ public final class ClientSession { public let client: MongoClient /// The session ID of this session. We only have a value available after we've started the libmongoc session. - public var id: Document? + internal var id: Document? /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. - public var serverId: Int? { + internal var serverId: Int? { switch self.state { case .notStarted, .ended: return nil @@ -80,7 +80,7 @@ public final class ClientSession { } /// Enum tracking the state of the transaction associated with this session. - public enum TransactionState: String, Decodable { + internal enum TransactionState: String, Decodable { /// There is no transaction in progress. case none /// A transaction has been started, but no operation has been sent to the server. @@ -126,7 +126,7 @@ public final class ClientSession { } /// The transaction state of this session. - public var transactionState: TransactionState? { + internal var transactionState: TransactionState? { switch self.state { case .notStarted, .ended: return nil diff --git a/Sources/MongoSwift/MongoClient.swift b/Sources/MongoSwift/MongoClient.swift index 52595424f..56383c429 100644 --- a/Sources/MongoSwift/MongoClient.swift +++ b/Sources/MongoSwift/MongoClient.swift @@ -72,19 +72,8 @@ public struct ClientOptions: CodingStrategyProvider, Decodable { /// Specifies a WriteConcern to use for the client. public var writeConcern: WriteConcern? - private enum CodingKeys: String, CodingKey { - case retryWrites, retryReads, readConcern, writeConcern, w, readConcernLevel, mode = "readPreference" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.readConcern = (try? container.decode(ReadConcern.self, forKey: .readConcern)) ?? - (try? ReadConcern(container.decode(String.self, forKey: .readConcernLevel))) - self.readPreference = try? ReadPreference(container.decode(ReadPreference.Mode.self, forKey: .mode)) - self.retryReads = try? container.decode(Bool.self, forKey: .retryReads) - self.retryWrites = try? container.decode(Bool.self, forKey: .retryWrites) - self.writeConcern = (try? container.decode(WriteConcern.self, forKey: .writeConcern)) ?? - (try? WriteConcern(w: container.decode(WriteConcern.W.self, forKey: .w))) + private enum CodingKeys: CodingKey { + case retryWrites, retryReads, readConcern, writeConcern } /// Convenience initializer allowing any/all parameters to be omitted or optional. diff --git a/Sources/MongoSwift/MongoCollection+BulkWrite.swift b/Sources/MongoSwift/MongoCollection+BulkWrite.swift index 558e0d039..d57f196be 100644 --- a/Sources/MongoSwift/MongoCollection+BulkWrite.swift +++ b/Sources/MongoSwift/MongoCollection+BulkWrite.swift @@ -218,6 +218,9 @@ internal struct BulkWriteOperation: Operation { if let transactionState = session?.transactionState, transactionState != .none { // Bulk write operations cannot have a write concern in a transaction. Default to // writeConcernAcknowledged = true. + if self.options?.writeConcern != nil { + throw LogicError(message: "Bulk write operations cannot have a write concern in a transaction") + } writeConcernAcknowledged = true } else { let writeConcern = WriteConcern(from: mongoc_bulk_operation_get_write_concern(bulk)) diff --git a/Sources/MongoSwift/MongoError.swift b/Sources/MongoSwift/MongoError.swift index 011f16165..4afe1f06e 100644 --- a/Sources/MongoSwift/MongoError.swift +++ b/Sources/MongoSwift/MongoError.swift @@ -165,7 +165,7 @@ public struct WriteConcernFailure: Codable { public let code: ServerErrorCode /// A human-readable string identifying write concern error. - public let codeName: String + public let codeName: String? /// A document identifying the write concern setting related to the error. public let details: Document? @@ -173,11 +173,15 @@ public struct WriteConcernFailure: Codable { /// A description of the error. public let message: String + /// Labels that may describe the context in which this error was thrown. + public let errorLabels: [String]? = nil + private enum CodingKeys: String, CodingKey { case code case codeName case details = "errInfo" case message = "errmsg" + case errorLabels } } @@ -251,18 +255,6 @@ private func parseMongocError(_ error: bson_error_t, reply: Document?) -> MongoE message: message, errorLabels: errorLabels ) - case (MONGOC_ERROR_WRITE_CONCERN, _): - var writeConcernErrorLabels = - reply?["writeConcernError"]?.documentValue?["errorLabels"]?.arrayValue?.asArrayOf(String.self) - if let errorLabels = errorLabels { - writeConcernErrorLabels = Array(Set((writeConcernErrorLabels ?? []) + errorLabels)) - } - return CommandError( - code: ServerErrorCode(code.rawValue), - codeName: codeName, - message: message, - errorLabels: writeConcernErrorLabels - ) case (MONGOC_ERROR_STREAM, _): return ConnectionError(message: message, errorLabels: errorLabels) case (MONGOC_ERROR_SERVER_SELECTION, MONGOC_ERROR_SERVER_SELECTION_FAILURE): @@ -291,6 +283,7 @@ internal func extractMongoError(error bsonError: bson_error_t, reply: Document? // if the reply is nil or writeErrors or writeConcernErrors aren't present, then this is likely a commandError. guard let serverReply: Document = reply, !(serverReply["writeErrors"]?.arrayValue ?? []).isEmpty || + !(serverReply["writeConcernError"]?.documentValue?.keys ?? []).isEmpty || !(serverReply["writeConcernErrors"]?.arrayValue ?? []).isEmpty else { return parseMongocError(bsonError, reply: reply) } @@ -402,11 +395,14 @@ internal func extractBulkWriteError( /// Extracts a `WriteConcernError` from a server reply. private func extractWriteConcernError(from reply: Document) throws -> WriteConcernFailure? { - guard let writeConcernErrors = reply["writeConcernErrors"]?.arrayValue?.compactMap({ $0.documentValue }), - !writeConcernErrors.isEmpty else { + if let writeConcernErrors = reply["writeConcernErrors"]?.arrayValue?.compactMap({ $0.documentValue }), + !writeConcernErrors.isEmpty { + return try BSONDecoder().decode(WriteConcernFailure.self, from: writeConcernErrors[0]) + } else if let writeConcernError = reply["writeConcernError"]?.documentValue { + return try BSONDecoder().decode(WriteConcernFailure.self, from: writeConcernError) + } else { return nil } - return try BSONDecoder().decode(WriteConcernFailure.self, from: writeConcernErrors[0]) } /// Internal function used by write methods performing single writes that are implemented via the bulk API. If the diff --git a/Sources/MongoSwiftSync/ClientSession.swift b/Sources/MongoSwiftSync/ClientSession.swift index f061aa817..f996fe6ec 100644 --- a/Sources/MongoSwiftSync/ClientSession.swift +++ b/Sources/MongoSwiftSync/ClientSession.swift @@ -48,18 +48,6 @@ public final class ClientSession { /// The options used to start this session. public var options: ClientSessionOptions? { self.asyncSession.options } - /// The session ID of this session. We only have a value available after we've started the libmongoc session. - public var id: Document? { self.asyncSession.id } - - /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. - public var serverId: Int? { self.asyncSession.serverId } - - /// Enum tracking the state of the transaction associated with this session. - public typealias TransactionState = MongoSwift.ClientSession.TransactionState - - /// The transaction state of this session. - public var transactionState: TransactionState? { self.asyncSession.transactionState } - /// Initializes a new client session. internal init(client: MongoClient, options: ClientSessionOptions?) { self.client = client diff --git a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift index 5981558f5..b075bef25 100644 --- a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift +++ b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift @@ -23,13 +23,15 @@ struct ClientSessionOp { } extension MongoSwiftSync.ClientSession { - var active: Bool { - self.asyncSession.active - } + internal var active: Bool { self.asyncSession.active } - var id: Document? { - self.asyncSession.id - } + internal var id: Document? { self.asyncSession.id } + + internal var serverId: Int? { self.asyncSession.serverId } + + internal typealias TransactionState = MongoSwift.ClientSession.TransactionState + + internal var transactionState: TransactionState? { self.asyncSession.transactionState } } final class SyncClientSessionTests: MongoSwiftTestCase { diff --git a/Tests/MongoSwiftSyncTests/CommandMonitoringTests.swift b/Tests/MongoSwiftSyncTests/CommandMonitoringTests.swift index 34ddd7bae..12196d510 100644 --- a/Tests/MongoSwiftSyncTests/CommandMonitoringTests.swift +++ b/Tests/MongoSwiftSyncTests/CommandMonitoringTests.swift @@ -79,31 +79,6 @@ private struct CMTestFile: Decodable { } } -extension ReadPreference.Mode: Decodable {} - -extension ReadPreference: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let mode = try container.decode(Mode.self, forKey: .mode) - switch mode { - case .primary: - self = .primary - case .primaryPreferred: - self = .primaryPreferred - case .secondary: - self = .secondary - case .secondaryPreferred: - self = .secondaryPreferred - case .nearest: - self = .nearest - } - } - - private enum CodingKeys: String, CodingKey { - case mode - } -} - /// A struct to hold the data for a single test from a CMTestFile. private struct CMTest: Decodable { struct Operation: Decodable { diff --git a/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift b/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift index 0e58eea5d..52eaa6570 100644 --- a/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift +++ b/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift @@ -9,7 +9,7 @@ private struct RetryableReadsTest: SpecTest { let operations: [TestOperationDescription] - let clientOptions: ClientOptions? + let clientOptions: TestClientOptions? let useMultipleMongoses: Bool? diff --git a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift index a87b32073..70862595d 100644 --- a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift +++ b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift @@ -101,7 +101,7 @@ final class RetryableWritesTests: MongoSwiftTestCase, FailPointConfigured { do { result = try test.operation.execute( on: .collection(collection), - sessionDict: [String: ClientSession]() + sessions: [String: ClientSession]() ) } catch { if let bulkError = error as? BulkWriteError { diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift new file mode 100644 index 000000000..30bdc2a2d --- /dev/null +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift @@ -0,0 +1,64 @@ +import MongoSwiftSync + +extension DatabaseOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init(readConcern: readConcern, readPreference: readPreference, writeConcern: writeConcern) + } + + private enum CodingKeys: CodingKey { + case readConcern, readPreference, writeConcern + } +} + +extension CollectionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init(readConcern: readConcern, writeConcern: writeConcern) + } + + private enum CodingKeys: CodingKey { + case readConcern, writeConcern + } +} + +extension ClientSessionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let causalConsistency = try? container.decode(Bool.self, forKey: .causalConsistency) + let defaultTransactionOptions = try? container.decode( + TransactionOptions.self, + forKey: .defaultTransactionOptions + ) + self.init(causalConsistency: causalConsistency, defaultTransactionOptions: defaultTransactionOptions) + } + + private enum CodingKeys: CodingKey { + case causalConsistency, defaultTransactionOptions + } +} + +extension TransactionOptions: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let maxCommitTimeMS = try? container.decode(Int64.self, forKey: .maxCommitTimeMS) + let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) + let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) + let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + self.init( + maxCommitTimeMS: maxCommitTimeMS, + readConcern: readConcern, + readPreference: readPreference, + writeConcern: writeConcern + ) + } + + private enum CodingKeys: CodingKey { + case maxCommitTimeMS, readConcern, readPreference, writeConcern + } +} diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift index a57ce1f06..6432535a4 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/Match.swift @@ -69,16 +69,13 @@ extension Array: Matchable where Element: Matchable { extension Document: Matchable { internal func contentMatches(expected: Document) -> Bool { for (eK, eV) in expected { - if eV != .null { - guard let aV = self[eK], aV.matches(expected: eV) else { - return false - } - } else { - // If the expected document has "key": null then the actual document must either have "key": null - // or no reference to "key". - if let aV = self[eK], aV.matches(expected: eV) { - return false - } + // If the expected document has "key": null then the actual document must either have "key": null + // or no reference to "key". + guard let aV = self[eK] else { + return eV == .null + } + guard aV.matches(expected: eV) else { + return false } } return true diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index 0fcc7ce5e..e3281612b 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -1,4 +1,5 @@ import Foundation +@testable import struct MongoSwift.ReadPreference import MongoSwiftSync import Nimble import TestsCommon @@ -20,8 +21,23 @@ internal struct TestCommandStartedEvent: Decodable, Matchable { case type = "command_started_event" } - internal init(from event: CommandStartedEvent) { - self.command = event.command + internal init(from event: CommandStartedEvent, sessionIds: [String: Document]? = nil) { + var command = event.command + + // If command started event has "lsid": Document(...), change the value to correpond to "session0", + // "session1", etc. + if let sessionIds = sessionIds, let sessionDoc = command["lsid"]?.documentValue { + for (sessionName, sessionId) in sessionIds where sessionId == sessionDoc { + command["lsid"] = .string(sessionName) + } + } + // If command is "findAndModify" and does not have key "new", add the default value "new": false. + // This is necessary because `libmongoc` only sends a value for "new" in a command if "new": true. + if event.commandName == "findAndModify" && command["new"] == nil { + command["new"] = .bool(false) + } + + self.command = command self.databaseName = event.databaseName self.commandName = event.commandName } @@ -52,12 +68,6 @@ internal struct TestCommandStartedEvent: Decodable, Matchable { return self.commandName.matches(expected: expected.commandName) && self.command.matches(expected: expected.command) } - - internal mutating func excludeKeys(keys: [String]) throws { - var newCommand = Document() - try self.command.copyElements(to: &newCommand, excluding: keys) - self.command = newCommand - } } /// Struct representing conditions that a deployment must meet in order for a test file to be run. @@ -122,6 +132,41 @@ internal enum TestData: Decodable { } } +public struct TestClientOptions: Decodable { + var readConcern: ReadConcern? + + var readPreference: ReadPreference? + + var retryReads: Bool? + + var retryWrites: Bool? + + var writeConcern: WriteConcern? + + private enum CodingKeys: String, CodingKey { + case retryReads, retryWrites, w, readConcernLevel, mode = "readPreference" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.readConcern = try? ReadConcern(container.decode(String.self, forKey: .readConcernLevel)) + self.readPreference = try? ReadPreference(container.decode(ReadPreference.Mode.self, forKey: .mode)) + self.retryReads = try? container.decode(Bool.self, forKey: .retryReads) + self.retryWrites = try? container.decode(Bool.self, forKey: .retryWrites) + self.writeConcern = try? WriteConcern(w: container.decode(WriteConcern.W.self, forKey: .w)) + } + + public func toClientOptions() -> ClientOptions { + ClientOptions( + readConcern: self.readConcern, + readPreference: self.readPreference, + retryReads: self.retryReads, + retryWrites: self.retryWrites, + writeConcern: self.writeConcern + ) + } +} + /// Struct representing the contents of a collection after a spec test has been run. internal struct CollectionTestInfo: Decodable { /// An optional name specifying a collection whose documents match the `data` field of this struct. @@ -231,7 +276,7 @@ internal protocol SpecTest: Decodable { var description: String { get } /// Options used to configure the `MongoClient` used for this test. - var clientOptions: ClientOptions? { get } + var clientOptions: TestClientOptions? { get } /// If true, the `MongoClient` for this test should be initialized with multiple mongos seed addresses. /// If false or omitted, only a single mongos address should be specified. @@ -257,6 +302,10 @@ internal protocol SpecTest: Decodable { /// Map of session names (e.g. "session0") to parameters to pass to `MongoClient.startSession()` when creating that /// session. var sessionOptions: [String: ClientSessionOptions]? { get } + + /// Array of session names (e.g. "session0", "session1") that the test refers to. Each session is proactively + /// started in `run()`. + static var sessionNames: [String] { get } } /// Default implementation of a test execution. @@ -265,6 +314,8 @@ extension SpecTest { var sessionOptions: [String: ClientSessionOptions]? { nil } + static var sessionNames: [String] { [] } + internal func run( parent: FailPointConfigured, dbName: String, @@ -277,7 +328,7 @@ extension SpecTest { print("Executing test: \(self.description)") - let clientOptions = self.clientOptions ?? ClientOptions(retryReads: true) + let clientOptions = self.clientOptions?.toClientOptions() ?? ClientOptions(retryReads: true) let client = try MongoClient.makeTestClient(options: clientOptions) let monitor = client.addCommandMonitor() @@ -294,12 +345,9 @@ extension SpecTest { } defer { parent.disableActiveFailPoint() } - // The spec tests refer to the sessions below. Proactively start them in case this spec test requires one. - let sessionsToStart = ["session0", "session1"] - - var sessionDict = [String: ClientSession]() - for session in sessionsToStart { - sessionDict[session] = client.startSession(options: self.sessionOptions?[session]) + var sessions = [String: ClientSession]() + for session in Self.sessionNames { + sessions[session] = client.startSession(options: self.sessionOptions?[session]) } var sessionIds = [String: Document]() @@ -310,19 +358,19 @@ extension SpecTest { client: client, dbName: dbName, collName: collName, - sessionDict: sessionDict + sessions: sessions ) } // Keep track of the session IDs assigned to each session. // Deinitialize each session thereby implicitly ending them. - for session in sessionDict.keys { - sessionIds[session] = sessionDict[session]?.id - sessionDict[session] = nil + for session in sessions.keys { + sessionIds[session] = sessions[session]?.id + sessions[session] = nil } } - let events = try monitor.commandStartedEvents().map { commandStartedEvent in - try processCommandStartedEvent(commandStartedEvent: commandStartedEvent, sessionIds: sessionIds) + let events = monitor.commandStartedEvents().map { commandStartedEvent in + TestCommandStartedEvent(from: commandStartedEvent, sessionIds: sessionIds) } if let expectations = self.expectations { @@ -334,31 +382,6 @@ extension SpecTest { } } - internal func processCommandStartedEvent( - commandStartedEvent: CommandStartedEvent, - sessionIds: [String: Document] - ) - throws -> TestCommandStartedEvent { - var event = TestCommandStartedEvent(from: commandStartedEvent) - try event.excludeKeys(keys: ["$db", "$clusterTime"]) - - // If command started event has "lsid": Document(...), change the value to correpond to "session0", - // "session1", etc. - if let sessionDoc = event.command["lsid"]?.documentValue { - for session in sessionIds.keys { - if let sessionId = sessionIds[session], sessionId == sessionDoc { - event.command["lsid"] = .string(session) - } - } - } - // If command is "findAndModify" and does not have key "new", add the default value "new": false. - if event.commandName == "findAndModify" && event.command["new"] == nil { - event.command["new"] = .bool(false) - } - - return event - } - internal func checkOutcome(outcome: TestOutcome, dbName: String, collName: String) throws { let client = try MongoClient.makeTestClient() let verifyColl = client.db(dbName).collection(collName) diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift index b9673ed1a..e0f457344 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift @@ -3,8 +3,42 @@ import Nimble import TestsCommon /// A enumeration of the different objects a `TestOperation` may be performed against. -enum TestOperationObject: String, Decodable { - case client, database, collection, gridfsbucket, session0, session1, testRunner +enum TestOperationObject: RawRepresentable, Decodable { + case client, database, collection, gridfsbucket, testRunner, session(String) + + public var rawValue: String { + switch self { + case .client: + return "client" + case .database: + return "database" + case .collection: + return "collection" + case .gridfsbucket: + return "gridfsbucket" + case .testRunner: + return "testRunner" + case let .session(sessionName): + return sessionName + } + } + + public init(rawValue: String) { + switch rawValue { + case "client": + self = .client + case "database": + self = .database + case "collection": + self = .collection + case "gridfsbucket": + self = .gridfsbucket + case "testRunner": + self = .testRunner + default: + self = .session(rawValue) + } + } } /// Struct containing an operation and an expected outcome. @@ -53,7 +87,7 @@ struct TestOperationDescription: Decodable { client: MongoClient, dbName: String, collName: String?, - sessionDict: [String: ClientSession] + sessions: [String: ClientSession] ) throws { let database = client.db(dbName, options: self.databaseOptions) var collection: MongoCollection? @@ -75,138 +109,30 @@ struct TestOperationDescription: Decodable { target = .collection(collection) case .gridfsbucket: throw TestError(message: "gridfs tests should be skipped") - case .session0: - guard let session0 = sessionDict["session0"] else { - throw TestError(message: "got session0 object but was not provided a session") - } - target = .session(session0) - case .session1: - guard let session1 = sessionDict["session1"] else { - throw TestError(message: "got session1 object but was not provided a session") + case let .session(sessionName): + guard let session = sessions[sessionName] else { + throw TestError(message: "got session object but was not provided a session") } - target = .session(session1) + target = .session(session) case .testRunner: target = .testRunner(database) } do { - let result = try self.operation.execute(on: target, sessionDict: sessionDict) + let result = try self.operation.execute(on: target, sessions: sessions) expect(self.error ?? false) .to(beFalse(), description: "expected to fail but succeeded with result \(String(describing: result))") if let expectedResult = self.result { - expect(result).to(equal(expectedResult)) + expect(result?.matches(expected: expectedResult)).to(beTrue()) } - } catch let error as CommandError { - try checkCommandError(error: error) - } catch let error as WriteError { - try checkWriteError(error: error) - } catch let error as BulkWriteError { - try checkBulkWriteError(error: error) - } catch let error as LogicError { - try checkLogicError(error: error) - } catch let error as InvalidArgumentError { - try checkInvalidArgumentError(error: error) - } catch let error as ConnectionError { - try checkConnectionError(error: error) } catch { - expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") - } - } - - public func checkErrorContains(error: Error, errorDescription: String) throws { - if case let .document(expectedResult) = self.result { - if let errorContains = expectedResult["errorContains"]?.stringValue { - expect(errorDescription.lowercased()).to(contain(errorContains.lowercased())) - } - } else { - expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") - } - } - - public func checkCodeName(error: Error, codeName: String) throws { - if case let .document(expectedResult) = self.result { - if let errorCodeName = expectedResult["errorCodeName"]?.stringValue, !codeName.isEmpty { - expect(codeName).to(equal(errorCodeName)) - } - } else { - expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") - } - } - - public func checkErrorLabels(error: Error, errorLabels: [String]?) throws { - // `configureFailPoint` command correctly handles error labels in MongoDB v4.3.1+ (see SERVER-43941). - // Do not check the "RetryableWriteError" error label until the spec test requirements are updated. - let skippedErrorLabels = ["RetryableWriteError"] - - if case let .document(expectedResult) = self.result { - if let errorLabelsContain = expectedResult["errorLabelsContain"]?.arrayValue, - let errorLabels = errorLabels { - errorLabelsContain.forEach { label in - if let label = label.stringValue, !skippedErrorLabels.contains(label) { - expect(errorLabels).to(contain(label)) - } - } + if case let .error(expectedErrorResult) = self.result { + try expectedErrorResult.checkErrorResult(error) + } else { + expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") } - if let errorLabelsOmit = expectedResult["errorLabelsOmit"]?.arrayValue, - let errorLabels = errorLabels { - errorLabelsOmit.forEach { label in - if let label = label.stringValue { - expect(errorLabels).toNot(contain(label)) - } - } - } - } else { - expect(self.error ?? false).to(beTrue(), description: "expected no error, got \(error)") } } - - public func checkCommandError(error: CommandError) throws { - try self.checkErrorContains(error: error, errorDescription: error.message) - try self.checkCodeName(error: error, codeName: error.codeName) - try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) - } - - public func checkWriteError(error: WriteError) throws { - if let writeFailure = error.writeFailure { - try self.checkErrorContains(error: error, errorDescription: writeFailure.message) - try self.checkCodeName(error: error, codeName: writeFailure.codeName) - } - if let writeConcernFailure = error.writeConcernFailure { - try self.checkErrorContains(error: error, errorDescription: writeConcernFailure.message) - try self.checkCodeName(error: error, codeName: writeConcernFailure.codeName) - } - try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) - } - - public func checkBulkWriteError(error: BulkWriteError) throws { - if let writeFailures = error.writeFailures { - try writeFailures.forEach { writeFailure in - try checkErrorContains(error: error, errorDescription: writeFailure.message) - try checkCodeName(error: error, codeName: writeFailure.codeName) - } - } - if let writeConcernFailure = error.writeConcernFailure { - try self.checkErrorContains(error: error, errorDescription: writeConcernFailure.message) - try self.checkCodeName(error: error, codeName: writeConcernFailure.codeName) - } - try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) - } - - public func checkLogicError(error: LogicError) throws { - try self.checkErrorContains(error: error, errorDescription: error.errorDescription) - // `LogicError` does not have error labels or a code name so there is no need to check them. - } - - public func checkInvalidArgumentError(error: InvalidArgumentError) throws { - try self.checkErrorContains(error: error, errorDescription: error.errorDescription) - // `InvalidArgumentError` does not have error labels or a code name so there is no need to check them. - } - - public func checkConnectionError(error: ConnectionError) throws { - try self.checkErrorContains(error: error, errorDescription: error.message) - try self.checkErrorLabels(error: error, errorLabels: error.errorLabels) - // `ConnectionError` does not have a code name so there is no need to check it. - } } /// Object in which an operation should be executed on. @@ -231,7 +157,7 @@ enum TestOperationTarget { /// Protocol describing the behavior of a spec test "operation" protocol TestOperation: Decodable { /// Execute the operation given the context. - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? } /// Wrapper around a `TestOperation.swift` allowing it to be decoded from a spec test. @@ -339,13 +265,13 @@ struct AnyTestOperation: Decodable, TestOperation { } } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { - try self.op.execute(on: target, sessionDict: sessionDict) + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + try self.op.execute(on: target, sessions: sessions) } } struct Aggregate: TestOperation { - let session: String + let session: String? let pipeline: [Document] let options: AggregateOptions @@ -354,22 +280,22 @@ struct Aggregate: TestOperation { init(from decoder: Decoder) throws { self.options = try AggregateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.pipeline = try container.decode([Document].self, forKey: .pipeline) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to aggregate") } return try TestOperationResult( - from: collection.aggregate(self.pipeline, options: self.options, session: sessionDict[self.session]) + from: collection.aggregate(self.pipeline, options: self.options, session: sessions[self.session ?? ""]) ) } } struct CountDocuments: TestOperation { - let session: String + let session: String? let filter: Document let options: CountDocumentsOptions @@ -378,21 +304,21 @@ struct CountDocuments: TestOperation { init(from decoder: Decoder) throws { self.options = try CountDocumentsOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to count") } return .int( - try collection.countDocuments(self.filter, options: self.options, session: sessionDict[self.session])) + try collection.countDocuments(self.filter, options: self.options, session: sessions[self.session ?? ""])) } } struct Distinct: TestOperation { - let session: String + let session: String? let fieldName: String let filter: Document? let options: DistinctOptions @@ -402,12 +328,12 @@ struct Distinct: TestOperation { init(from decoder: Decoder) throws { self.options = try DistinctOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.fieldName = try container.decode(String.self, forKey: .fieldName) self.filter = try container.decodeIfPresent(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to distinct") } @@ -415,14 +341,14 @@ struct Distinct: TestOperation { fieldName: self.fieldName, filter: self.filter ?? [:], options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return .array(result) } } struct Find: TestOperation { - let session: String + let session: String? let filter: Document let options: FindOptions @@ -431,22 +357,22 @@ struct Find: TestOperation { init(from decoder: Decoder) throws { self.options = try FindOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = (try? container.decode(Document.self, forKey: .filter)) ?? Document() } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to find") } return try TestOperationResult( - from: collection.find(self.filter, options: self.options, session: sessionDict[self.session]) + from: collection.find(self.filter, options: self.options, session: sessions[self.session ?? ""]) ) } } struct FindOne: TestOperation { - let session: String + let session: String? let filter: Document let options: FindOneOptions @@ -455,22 +381,22 @@ struct FindOne: TestOperation { init(from decoder: Decoder) throws { self.options = try FindOneOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOne") } return try TestOperationResult( - from: collection.findOne(self.filter, options: self.options, session: sessionDict[self.session]) + from: collection.findOne(self.filter, options: self.options, session: sessions[self.session ?? ""]) ) } } struct UpdateOne: TestOperation { - let session: String + let session: String? let filter: Document let update: Document let options: UpdateOptions @@ -480,12 +406,12 @@ struct UpdateOne: TestOperation { init(from decoder: Decoder) throws { self.options = try UpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to updateOne") } @@ -494,14 +420,14 @@ struct UpdateOne: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: result) } } struct UpdateMany: TestOperation { - let session: String + let session: String? let filter: Document let update: Document let options: UpdateOptions @@ -511,12 +437,12 @@ struct UpdateMany: TestOperation { init(from decoder: Decoder) throws { self.options = try UpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to ") } @@ -525,14 +451,14 @@ struct UpdateMany: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: result) } } struct DeleteMany: TestOperation { - let session: String + let session: String? let filter: Document let options: DeleteOptions @@ -541,21 +467,22 @@ struct DeleteMany: TestOperation { init(from decoder: Decoder) throws { self.options = try DeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to deleteMany") } - let result = try collection.deleteMany(self.filter, options: self.options, session: sessionDict[self.session]) + let result = + try collection.deleteMany(self.filter, options: self.options, session: sessions[self.session ?? ""]) return TestOperationResult(from: result) } } struct DeleteOne: TestOperation { - let session: String + let session: String? let filter: Document let options: DeleteOptions @@ -564,41 +491,41 @@ struct DeleteOne: TestOperation { init(from decoder: Decoder) throws { self.options = try DeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to deleteOne") } - let result = try collection.deleteOne(self.filter, options: self.options, session: sessionDict[self.session]) + let result = try collection.deleteOne(self.filter, options: self.options, session: sessions[self.session ?? ""]) return TestOperationResult(from: result) } } struct InsertOne: TestOperation { - let session: String + let session: String? let document: Document private enum CodingKeys: String, CodingKey { case session, document } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.document = try container.decode(Document.self, forKey: .document) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to insertOne") } - return TestOperationResult(from: try collection.insertOne(self.document, session: sessionDict[self.session])) + return TestOperationResult(from: try collection.insertOne(self.document, session: sessions[self.session ?? ""])) } } struct InsertMany: TestOperation { - let session: String + let session: String? let documents: [Document] let options: InsertManyOptions @@ -607,18 +534,18 @@ struct InsertMany: TestOperation { init(from decoder: Decoder) throws { self.options = (try? InsertManyOptions(from: decoder)) ?? InsertManyOptions() let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.documents = try container.decode([Document].self, forKey: .documents) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to insertMany") } let result = try collection.insertMany( self.documents, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: result) } @@ -687,7 +614,7 @@ extension WriteModel: Decodable { } struct BulkWrite: TestOperation { - let session: String + let session: String? let requests: [WriteModel] let options: BulkWriteOptions @@ -696,21 +623,22 @@ struct BulkWrite: TestOperation { init(from decoder: Decoder) throws { self.options = (try? BulkWriteOptions(from: decoder)) ?? BulkWriteOptions() let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.requests = try container.decode([WriteModel].self, forKey: .requests) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to bulk write") } - let result = try collection.bulkWrite(self.requests, options: self.options, session: sessionDict[self.session]) + let result = + try collection.bulkWrite(self.requests, options: self.options, session: sessions[self.session ?? ""]) return TestOperationResult(from: result) } } struct FindOneAndUpdate: TestOperation { - let session: String + let session: String? let filter: Document let update: Document let options: FindOneAndUpdateOptions @@ -720,12 +648,12 @@ struct FindOneAndUpdate: TestOperation { init(from decoder: Decoder) throws { self.options = try FindOneAndUpdateOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) self.update = try container.decode(Document.self, forKey: .update) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndUpdate") } @@ -733,14 +661,14 @@ struct FindOneAndUpdate: TestOperation { filter: self.filter, update: self.update, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: doc) } } struct FindOneAndDelete: TestOperation { - let session: String + let session: String? let filter: Document let options: FindOneAndDeleteOptions @@ -749,25 +677,25 @@ struct FindOneAndDelete: TestOperation { init(from decoder: Decoder) throws { self.options = try FindOneAndDeleteOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndDelete") } let result = try collection.findOneAndDelete( self.filter, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: result) } } struct FindOneAndReplace: TestOperation { - let session: String + let session: String? let filter: Document let replacement: Document let options: FindOneAndReplaceOptions @@ -777,12 +705,12 @@ struct FindOneAndReplace: TestOperation { init(from decoder: Decoder) throws { self.options = try FindOneAndReplaceOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) self.replacement = try container.decode(Document.self, forKey: .replacement) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to findOneAndReplace") } @@ -790,14 +718,14 @@ struct FindOneAndReplace: TestOperation { filter: self.filter, replacement: self.replacement, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] ) return TestOperationResult(from: result) } } struct ReplaceOne: TestOperation { - let session: String + let session: String? let filter: Document let replacement: Document let options: ReplaceOptions @@ -807,12 +735,12 @@ struct ReplaceOne: TestOperation { init(from decoder: Decoder) throws { self.options = try ReplaceOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.filter = try container.decode(Document.self, forKey: .filter) self.replacement = try container.decode(Document.self, forKey: .replacement) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to replaceOne") } @@ -820,24 +748,24 @@ struct ReplaceOne: TestOperation { filter: self.filter, replacement: self.replacement, options: self.options, - session: sessionDict[self.session] + session: sessions[self.session ?? ""] )) } } struct RenameCollection: TestOperation { - let session: String + let session: String? let to: String private enum CodingKeys: String, CodingKey { case session, to } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.to = try container.decode(String.self, forKey: .to) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to renameCollection") } @@ -848,13 +776,13 @@ struct RenameCollection: TestOperation { "to": .string(databaseName + "." + self.to) ] return try TestOperationResult( - from: collection._client.db("admin").runCommand(cmd, session: sessionDict[self.session]) + from: collection._client.db("admin").runCommand(cmd, session: sessions[self.session ?? ""]) ) } } struct Drop: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to drop") @@ -865,7 +793,7 @@ struct Drop: TestOperation { } struct ListDatabaseNames: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabaseNames") @@ -875,7 +803,7 @@ struct ListDatabaseNames: TestOperation { } struct ListIndexes: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexes") @@ -885,7 +813,7 @@ struct ListIndexes: TestOperation { } struct ListIndexNames: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexNames") @@ -895,7 +823,7 @@ struct ListIndexNames: TestOperation { } struct ListDatabases: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") @@ -905,7 +833,7 @@ struct ListDatabases: TestOperation { } struct ListMongoDatabases: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") @@ -916,7 +844,7 @@ struct ListMongoDatabases: TestOperation { } struct ListCollections: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollections") @@ -926,7 +854,7 @@ struct ListCollections: TestOperation { } struct ListMongoCollections: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionObjects") @@ -937,7 +865,7 @@ struct ListMongoCollections: TestOperation { } struct ListCollectionNames: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionNames") @@ -947,7 +875,7 @@ struct ListCollectionNames: TestOperation { } struct Watch: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { switch target { case let .client(client): @@ -964,7 +892,7 @@ struct Watch: TestOperation { } struct EstimatedDocumentCount: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to estimatedDocumentCount") @@ -989,7 +917,7 @@ struct StartTransaction: TestOperation { self.options = try container.decode(TransactionOptions.self, forKey: .options) } - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .session(session) = target else { throw TestError(message: "session not provided to startTransaction") @@ -1000,71 +928,71 @@ struct StartTransaction: TestOperation { } struct CommitTransaction: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .session(session) = target else { throw TestError(message: "session not provided to commitTransaction") } - _ = try session.commitTransaction() + try session.commitTransaction() return nil } } struct AbortTransaction: TestOperation { - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .session(session) = target else { throw TestError(message: "session not provided to abortTransaction") } - _ = try session.abortTransaction() + try session.abortTransaction() return nil } } struct CreateCollection: TestOperation { - let session: String + let session: String? let collection: String private enum CodingKeys: String, CodingKey { case session, collection } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.collection = try container.decode(String.self, forKey: .collection) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to createCollection") } - _ = try database.createCollection(self.collection, session: sessionDict[self.session]) + _ = try database.createCollection(self.collection, session: sessions[self.session ?? ""]) return nil } } struct DropCollection: TestOperation { - let session: String + let session: String? let collection: String private enum CodingKeys: String, CodingKey { case session, collection } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.collection = try container.decode(String.self, forKey: .collection) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to dropCollection") } - _ = try database.collection(self.collection).drop(session: sessionDict[self.session]) + _ = try database.collection(self.collection).drop(session: sessions[self.session ?? ""]) return nil } } struct CreateIndex: TestOperation { - let session: String + let session: String? let name: String let keys: Document @@ -1072,23 +1000,23 @@ struct CreateIndex: TestOperation { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.name = try container.decode(String.self, forKey: .name) self.keys = try container.decode(Document.self, forKey: .keys) } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to createIndex") } let indexOptions = IndexOptions(name: self.name) - _ = try collection.createIndex(self.keys, indexOptions: indexOptions, session: sessionDict[self.session]) + _ = try collection.createIndex(self.keys, indexOptions: indexOptions, session: sessions[self.session ?? ""]) return nil } } struct RunCommand: TestOperation { - let session: String + let session: String? let command: Document let readPreference: ReadPreference @@ -1096,13 +1024,13 @@ struct RunCommand: TestOperation { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.command = try container.decode(Document.self, forKey: .command) self.readPreference = (try? container.decode(ReadPreference.self, forKey: .readPreference)) ?? ReadPreference.primary } - func execute(on target: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to runCommand") } @@ -1110,18 +1038,9 @@ struct RunCommand: TestOperation { let result = try database.runCommand( self.command, options: runCommandOptions, - session: sessionDict[self.session] - ) - - var refinedResult = Document() - try result.copyElements( - to: &refinedResult, - excluding: [ - "opTime", "electionId", "ok", "$clusterTime", "signature", "keyId", "operationTime", - "recoveryToken" - ] + session: sessions[self.session ?? ""] ) - return TestOperationResult(from: refinedResult) + return TestOperationResult(from: result) } } @@ -1129,16 +1048,14 @@ struct AssertCollectionExists: TestOperation { let database: String let collection: String - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .testRunner(database) = target else { throw TestError(message: "database not provided to assertCollectionExists") } let client = try MongoClient.makeTestClient() let collectionNames = try client.db(database.name).listCollectionNames(session: nil) - guard collectionNames.contains(self.collection) else { - throw TestError(message: "expected \(database).\(self.collection) to exist, but it does not") - } + expect(collectionNames).to(contain(self.collection)) return nil } } @@ -1147,16 +1064,14 @@ struct AssertCollectionNotExists: TestOperation { let database: String let collection: String - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .testRunner(database) = target else { throw TestError(message: "database not provided to assertCollectionNotExists") } let client = try MongoClient.makeTestClient() let collectionNames = try client.db(database.name).listCollectionNames(session: nil) - guard !collectionNames.contains(self.collection) else { - throw TestError(message: "expected \(database).\(self.collection) to not exist, but it does") - } + expect(collectionNames).toNot(contain(self.collection)) return nil } } @@ -1166,18 +1081,14 @@ struct AssertIndexExists: TestOperation { let collection: String let index: String - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .testRunner(database) = target else { throw TestError(message: "database not provided to assertIndexExists") } let client = try MongoClient.makeTestClient() let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) - guard indexNames.contains(self.index) else { - throw TestError( - message: "expected \(self.index) to exist in \(database).\(self.collection), but it does not" - ) - } + expect(indexNames).to(contain(self.index)) return nil } } @@ -1187,83 +1098,73 @@ struct AssertIndexNotExists: TestOperation { let collection: String let index: String - func execute(on target: TestOperationTarget, sessionDict _: [String: ClientSession]) + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .testRunner(database) = target else { throw TestError(message: "database not provided to assertIndexNotExists") } let client = try MongoClient.makeTestClient() let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) - guard !indexNames.contains(self.index) else { - throw TestError( - message: "expected \(self.index) to not exist in \(database).\(self.collection), but it does" - ) - } + expect(indexNames).toNot(contain(self.index)) return nil } } struct AssertSessionPinned: TestOperation { - let session: String + let session: String? private enum CodingKeys: String, CodingKey { case session } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) } - func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { - guard let serverId = sessionDict[self.session]?.serverId else { + func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") } - guard serverId != 0 else { - throw TestError(message: "expected session to be pinned, got unpinned") - } + expect(serverId).to(equal(0)) return nil } } struct AssertSessionUnpinned: TestOperation { - let session: String + let session: String? private enum CodingKeys: String, CodingKey { case session } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) } - func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { - guard let serverId = sessionDict[self.session]?.serverId else { + func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") } - guard serverId == 0 else { - throw TestError(message: "expected session to be pinned, got unpinned") - } + expect(serverId).toNot(equal(0)) return nil } } struct AssertSessionTransactionState: TestOperation { - let session: String + let session: String? let state: ClientSession.TransactionState private enum CodingKeys: String, CodingKey { case session, state } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = (try? container.decode(String.self, forKey: .session)) ?? "" + self.session = try container.decodeIfPresent(String.self, forKey: .session) self.state = try container.decode(ClientSession.TransactionState.self, forKey: .state) } - func execute(on _: TestOperationTarget, sessionDict: [String: ClientSession]) throws -> TestOperationResult? { - guard let transactionState = sessionDict[self.session]?.transactionState else { + func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard let transactionState = sessions[self.session ?? ""]?.transactionState else { throw TestError(message: "active session not provided to assertSessionTransactionState") } - guard self.state == transactionState else { - throw TestError(message: "expected transaction state to be \(self.state), got \(transactionState)") - } + expect(transactionState).to(equal(self.state)) return nil } } @@ -1272,7 +1173,7 @@ struct AssertSessionTransactionState: TestOperation { struct NotImplemented: TestOperation { internal let name: String - func execute(on _: TestOperationTarget, sessionDict _: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on _: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { throw TestError(message: "\(self.name) not implemented in the driver, skip this test") } } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift index 97e0233df..02107ce18 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift @@ -1,7 +1,9 @@ import MongoSwiftSync +import Nimble +import TestsCommon -/// Enum encapsulating the possible results returned from CRUD operations. -enum TestOperationResult: Decodable, Equatable { +/// Enum encapsulating the possible results returned from test operations. +enum TestOperationResult: Decodable, Equatable, Matchable { /// Crud operation returns an int (e.g. `count`). case int(Int) @@ -14,11 +16,18 @@ enum TestOperationResult: Decodable, Equatable { /// Result of CRUD operations whose result can be represented by a `BulkWriteResult` (e.g. `InsertOne`). case bulkWrite(BulkWriteResult) + /// Result of test operations that are expected to return an error (e.g. `CommandError`, `WriteError`). + case error(ErrorResult) + public init?(from doc: Document?) { guard let doc = doc else { return nil } - self = .document(doc) + if !ErrorResult.errorKeys.isDisjoint(with: doc.keys) { + self = .error(ErrorResult(from: doc)) + } else { + self = .document(doc) + } } public init?(from result: BulkWriteResultConvertible?) { @@ -49,7 +58,11 @@ enum TestOperationResult: Decodable, Equatable { } else if let array = try? [BSON](from: decoder) { self = .array(array) } else if let doc = try? Document(from: decoder) { - self = .document(doc) + if !ErrorResult.errorKeys.isDisjoint(with: doc.keys) { + self = .error(ErrorResult(from: doc)) + } else { + self = .document(doc) + } } else { throw DecodingError.valueNotFound( TestOperationResult.self, @@ -71,12 +84,33 @@ enum TestOperationResult: Decodable, Equatable { return lhsArray == rhsArray case let (.document(lhsDoc), .document(rhsDoc)): return lhsDoc.sortedEquals(rhsDoc) + case let (.error(lhsErr), .error(rhsErr)): + return lhsErr == rhsErr + default: + return false + } + } + + internal func contentMatches(expected: TestOperationResult) -> Bool { + switch (self, expected) { + case let (.bulkWrite(bw), .bulkWrite(expectedBw)): + return bw.matches(expected: expectedBw) + case let (.int(int), .int(expectedInt)): + return int.matches(expected: expectedInt) + case let (.array(array), .array(expectedArray)): + return array.matches(expected: expectedArray) + case let (.document(doc), .document(expectedDoc)): + return doc.matches(expected: expectedDoc) + case let (.error(error), .error(expectedError)): + return error.matches(expected: expectedError) default: return false } } } +extension BulkWriteResult: Matchable {} + /// Protocol for allowing conversion from different result types to `BulkWriteResult`. /// This behavior is used to funnel the various CRUD results into the `.bulkWrite` `TestOperationResult` case. protocol BulkWriteResultConvertible { @@ -120,3 +154,128 @@ extension DeleteResult: BulkWriteResultConvertible { BulkWriteResult.new(deletedCount: self.deletedCount) } } + +struct ErrorResult: Equatable, Matchable { + internal static let errorKeys: Set = ["errorContains", "errorCodeName", "errorLabelsContain", "errorLabelsOmit"] + + internal var errorContains: String? + + internal var errorCodeName: String? + + internal var errorLabelsContain: [String]? + + internal var errorLabelsOmit: [String]? + + public init(from doc: Document) { + let errorLabelsContain = doc["errorLabelsContain"]?.arrayValue?.compactMap { $0.stringValue } + let errorLabelsOmit = doc["errorLabelsOmit"]?.arrayValue?.compactMap { $0.stringValue } + + self.errorContains = doc["errorContains"]?.stringValue + self.errorCodeName = doc["errorCodeName"]?.stringValue + self.errorLabelsContain = errorLabelsContain?.sorted() + self.errorLabelsOmit = errorLabelsOmit?.sorted() + } + + internal static func == (lhs: ErrorResult, rhs: ErrorResult) -> Bool { + lhs.errorContains == rhs.errorContains && + lhs.errorCodeName == rhs.errorCodeName && + lhs.errorLabelsContain == rhs.errorLabelsContain && + lhs.errorLabelsOmit == rhs.errorLabelsOmit + } + + public func checkErrorResult(_ error: Error) throws { + if let commandError = error as? CommandError { + try self.checkCommandError(commandError) + } else if let writeError = error as? WriteError { + try self.checkWriteError(writeError) + } else if let bulkWriteError = error as? BulkWriteError { + try self.checkBulkWriteError(bulkWriteError) + } else if let logicError = error as? LogicError { + try self.checkLogicError(logicError) + } else if let invalidArgumentError = error as? InvalidArgumentError { + try self.checkInvalidArgumentError(invalidArgumentError) + } else if let connectionError = error as? ConnectionError { + try self.checkConnectionError(connectionError) + } else { + throw TestError(message: "checked ErrorResult with unhandled error \(error)") + } + } + + internal func checkErrorContains(errorDescription: String) throws { + if let errorContains = self.errorContains { + expect(errorDescription.lowercased()).to(contain(errorContains.lowercased())) + } + } + + internal func checkCodeName(codeName: String?) throws { + if let errorCodeName = self.errorCodeName, let codeName = codeName, !codeName.isEmpty { + expect(codeName).to(equal(errorCodeName)) + } + } + + internal func checkErrorLabels(errorLabels: [String]?) throws { + // `configureFailPoint` command correctly handles error labels in MongoDB v4.3.1+ (see SERVER-43941). + // Do not check the "RetryableWriteError" error label until the spec test requirements are updated. + let skippedErrorLabels = ["RetryableWriteError"] + + if let errorLabelsContain = self.errorLabelsContain, let errorLabels = errorLabels { + errorLabelsContain.forEach { label in + if !skippedErrorLabels.contains(label) { + expect(errorLabels).to(contain(label)) + } + } + } + if let errorLabelsOmit = self.errorLabelsOmit, let errorLabels = errorLabels { + errorLabelsOmit.forEach { label in + expect(errorLabels).toNot(contain(label)) + } + } + } + + internal func checkCommandError(_ error: CommandError) throws { + try self.checkErrorContains(errorDescription: error.message) + try self.checkCodeName(codeName: error.codeName) + try self.checkErrorLabels(errorLabels: error.errorLabels) + } + + internal func checkWriteError(_ error: WriteError) throws { + if let writeFailure = error.writeFailure { + try self.checkErrorContains(errorDescription: writeFailure.message) + try self.checkCodeName(codeName: writeFailure.codeName) + } + if let writeConcernFailure = error.writeConcernFailure { + try self.checkErrorContains(errorDescription: writeConcernFailure.message) + try self.checkCodeName(codeName: writeConcernFailure.codeName) + } + try self.checkErrorLabels(errorLabels: error.errorLabels) + } + + internal func checkBulkWriteError(_ error: BulkWriteError) throws { + if let writeFailures = error.writeFailures { + try writeFailures.forEach { writeFailure in + try checkErrorContains(errorDescription: writeFailure.message) + try checkCodeName(codeName: writeFailure.codeName) + } + } + if let writeConcernFailure = error.writeConcernFailure { + try self.checkErrorContains(errorDescription: writeConcernFailure.message) + try self.checkCodeName(codeName: writeConcernFailure.codeName) + } + } + + internal func checkLogicError(_ error: LogicError) throws { + try self.checkErrorContains(errorDescription: error.errorDescription) + // `LogicError` does not have error labels or a code name so there is no need to check them. + } + + internal func checkInvalidArgumentError(_ error: InvalidArgumentError) throws { + try self.checkErrorContains(errorDescription: error.errorDescription) + // `InvalidArgumentError` does not have error labels or a code name so there is no need to check them. + } + + internal func checkConnectionError(_ error: ConnectionError) throws { + try self.checkErrorContains(errorDescription: error.message) + try self.checkErrorLabels(errorLabels: error.errorLabels) + // `ConnectionError` does not have a code name so there is no need to check it. + } +} diff --git a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift index d7caefb31..2d1455c2e 100644 --- a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift +++ b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift @@ -69,7 +69,7 @@ internal struct ChangeStreamTestOperation: Decodable { internal func execute(using client: MongoClient) throws -> TestOperationResult? { let db = client.db(self.database) let coll = db.collection(self.collection) - return try self.operation.execute(on: .collection(coll), sessionDict: [String: ClientSession]()) + return try self.operation.execute(on: .collection(coll), sessions: [String: ClientSession]()) } } diff --git a/Tests/MongoSwiftSyncTests/TransactionsTests.swift b/Tests/MongoSwiftSyncTests/TransactionsTests.swift index 4d6bbc5cf..927dd005c 100644 --- a/Tests/MongoSwiftSyncTests/TransactionsTests.swift +++ b/Tests/MongoSwiftSyncTests/TransactionsTests.swift @@ -15,13 +15,15 @@ private struct TransactionsTest: SpecTest { let useMultipleMongoses: Bool? - let clientOptions: ClientOptions? + let clientOptions: TestClientOptions? let failPoint: FailPoint? let sessionOptions: [String: ClientSessionOptions]? let expectations: [TestCommandStartedEvent]? + + static let sessionNames: [String] = ["session0", "session1"] } /// Struct representing a single transactions spec test JSON file. @@ -74,66 +76,3 @@ final class TransactionsTests: MongoSwiftTestCase, FailPointConfigured { } } } - -extension DatabaseOptions: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) - self.init(readConcern: readConcern, readPreference: readPreference, writeConcern: writeConcern) - } - - private enum CodingKeys: CodingKey { - case readConcern, readPreference, writeConcern - } -} - -extension CollectionOptions: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) - self.init(readConcern: readConcern, writeConcern: writeConcern) - } - - private enum CodingKeys: CodingKey { - case readConcern, writeConcern - } -} - -extension ClientSessionOptions: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let causalConsistency = try? container.decode(Bool.self, forKey: .causalConsistency) - let defaultTransactionOptions = try? container.decode( - TransactionOptions.self, - forKey: .defaultTransactionOptions - ) - self.init(causalConsistency: causalConsistency, defaultTransactionOptions: defaultTransactionOptions) - } - - private enum CodingKeys: CodingKey { - case causalConsistency, defaultTransactionOptions - } -} - -extension TransactionOptions: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let maxCommitTimeMS = try? container.decode(Int64.self, forKey: .maxCommitTimeMS) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) - self.init( - maxCommitTimeMS: maxCommitTimeMS, - readConcern: readConcern, - readPreference: readPreference, - writeConcern: writeConcern - ) - } - - private enum CodingKeys: CodingKey { - case maxCommitTimeMS, readConcern, readPreference, writeConcern - } -} From f912db97497fd8a434919c8beeddc7b2557071da Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Wed, 8 Apr 2020 14:44:03 -0400 Subject: [PATCH 04/12] Responded to comments 2.0 --- Sources/MongoSwift/ClientSession.swift | 3 +- .../MongoCollection+BulkWrite.swift | 7 +- .../SpecTestRunner/SpecTest.swift | 25 ++- .../SpecTestRunner/TestOperation.swift | 82 +------ .../SpecTestRunner/TestOperationResult.swift | 202 +++++++++--------- 5 files changed, 131 insertions(+), 188 deletions(-) diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index 535997eb3..5cf657243 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -66,7 +66,8 @@ public final class ClientSession { /// The client used to start this session. public let client: MongoClient - /// The session ID of this session. We only have a value available after we've started the libmongoc session. + /// The session ID of this session. This is internal for now because we only have a value available after we've + /// started the libmongoc session. internal var id: Document? /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. diff --git a/Sources/MongoSwift/MongoCollection+BulkWrite.swift b/Sources/MongoSwift/MongoCollection+BulkWrite.swift index d57f196be..5316ffaec 100644 --- a/Sources/MongoSwift/MongoCollection+BulkWrite.swift +++ b/Sources/MongoSwift/MongoCollection+BulkWrite.swift @@ -217,9 +217,12 @@ internal struct BulkWriteOperation: Operation { var writeConcernAcknowledged: Bool if let transactionState = session?.transactionState, transactionState != .none { // Bulk write operations cannot have a write concern in a transaction. Default to - // writeConcernAcknowledged = true. + // writeConcernAcknowledged = true. Since `libmongoc` returns a null write concern from bulk write + // operations in a transaction, we cannot call `mongoc_bulk_operation_get_write_concern`. if self.options?.writeConcern != nil { - throw LogicError(message: "Bulk write operations cannot have a write concern in a transaction") + throw InvalidArgumentError( + message: "Bulk write operations cannot have a write concern in atransaction" + ) } writeConcernAcknowledged = true } else { diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index e3281612b..0df0c73c0 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -328,7 +328,7 @@ extension SpecTest { print("Executing test: \(self.description)") - let clientOptions = self.clientOptions?.toClientOptions() ?? ClientOptions(retryReads: true) + let clientOptions = self.clientOptions?.toClientOptions() let client = try MongoClient.makeTestClient(options: clientOptions) let monitor = client.addCommandMonitor() @@ -377,18 +377,21 @@ extension SpecTest { expect(events).to(match(expectations), description: self.description) } - if let outcome = self.outcome { - try self.checkOutcome(outcome: outcome, dbName: dbName, collName: collName!) - } + try self.checkOutcome(dbName: dbName, collName: collName) } - internal func checkOutcome(outcome: TestOutcome, dbName: String, collName: String) throws { - let client = try MongoClient.makeTestClient() - let verifyColl = client.db(dbName).collection(collName) - let foundDocs = try Array(verifyColl.find().all()) - expect(foundDocs.count).to(equal(outcome.collection.data.count)) - zip(foundDocs, outcome.collection.data).forEach { - expect($0).to(sortedEqual($1), description: self.description) + internal func checkOutcome(dbName: String, collName: String?) throws { + if let outcome = self.outcome { + guard let collName = collName else { + throw TestError(message: "outcome specifies a collection but spec test omits collection name") + } + let client = try MongoClient.makeTestClient() + let verifyColl = client.db(dbName).collection(collName) + let foundDocs = try verifyColl.find().all() + expect(foundDocs.count).to(equal(outcome.collection.data.count)) + zip(foundDocs, outcome.collection.data).forEach { + expect($0).to(sortedEqual($1), description: self.description) + } } } } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift index e0f457344..8ca43df09 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift @@ -133,6 +133,8 @@ struct TestOperationDescription: Decodable { } } } + + // swiftlint:enable cyclomatic_complexity } /// Object in which an operation should be executed on. @@ -211,7 +213,7 @@ struct AnyTestOperation: Decodable, TestOperation { case "rename": self.op = try container.decode(RenameCollection.self, forKey: .arguments) case "startTransaction": - self.op = (try? container.decode(StartTransaction.self, forKey: .arguments)) ?? StartTransaction() + self.op = (try container.decodeIfPresent(StartTransaction.self, forKey: .arguments)) ?? StartTransaction() case "createCollection": self.op = try container.decode(CreateCollection.self, forKey: .arguments) case "dropCollection": @@ -358,7 +360,7 @@ struct Find: TestOperation { self.options = try FindOptions(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.filter = (try? container.decode(Document.self, forKey: .filter)) ?? Document() + self.filter = (try container.decodeIfPresent(Document.self, forKey: .filter)) ?? Document() } func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { @@ -757,14 +759,6 @@ struct RenameCollection: TestOperation { let session: String? let to: String - private enum CodingKeys: String, CodingKey { case session, to } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.to = try container.decode(String.self, forKey: .to) - } - func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to renameCollection") @@ -904,19 +898,10 @@ struct EstimatedDocumentCount: TestOperation { struct StartTransaction: TestOperation { let options: TransactionOptions - private enum CodingKeys: CodingKey { - case options - } - init() { self.options = TransactionOptions() } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.options = try container.decode(TransactionOptions.self, forKey: .options) - } - func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { guard case let .session(session) = target else { @@ -953,14 +938,6 @@ struct CreateCollection: TestOperation { let session: String? let collection: String - private enum CodingKeys: String, CodingKey { case session, collection } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.collection = try container.decode(String.self, forKey: .collection) - } - func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to createCollection") @@ -974,14 +951,6 @@ struct DropCollection: TestOperation { let session: String? let collection: String - private enum CodingKeys: String, CodingKey { case session, collection } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.collection = try container.decode(String.self, forKey: .collection) - } - func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { throw TestError(message: "database not provided to dropCollection") @@ -996,15 +965,6 @@ struct CreateIndex: TestOperation { let name: String let keys: Document - private enum CodingKeys: String, CodingKey { case session, name, keys } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.name = try container.decode(String.self, forKey: .name) - self.keys = try container.decode(Document.self, forKey: .keys) - } - func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to createIndex") @@ -1018,17 +978,7 @@ struct CreateIndex: TestOperation { struct RunCommand: TestOperation { let session: String? let command: Document - let readPreference: ReadPreference - - private enum CodingKeys: String, CodingKey { case session, command, readPreference } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.command = try container.decode(Document.self, forKey: .command) - self.readPreference = (try? container.decode(ReadPreference.self, forKey: .readPreference)) ?? - ReadPreference.primary - } + let readPreference: ReadPreference? func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .database(database) = target else { @@ -1113,13 +1063,6 @@ struct AssertIndexNotExists: TestOperation { struct AssertSessionPinned: TestOperation { let session: String? - private enum CodingKeys: String, CodingKey { case session } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - } - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") @@ -1132,13 +1075,6 @@ struct AssertSessionPinned: TestOperation { struct AssertSessionUnpinned: TestOperation { let session: String? - private enum CodingKeys: String, CodingKey { case session } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - } - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") @@ -1152,14 +1088,6 @@ struct AssertSessionTransactionState: TestOperation { let session: String? let state: ClientSession.TransactionState - private enum CodingKeys: String, CodingKey { case session, state } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.state = try container.decode(ClientSession.TransactionState.self, forKey: .state) - } - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard let transactionState = sessions[self.session ?? ""]?.transactionState else { throw TestError(message: "active session not provided to assertSessionTransactionState") diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift index 02107ce18..97a9e86ed 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift @@ -1,6 +1,7 @@ import MongoSwiftSync import Nimble import TestsCommon +import XCTest /// Enum encapsulating the possible results returned from test operations. enum TestOperationResult: Decodable, Equatable, Matchable { @@ -23,11 +24,7 @@ enum TestOperationResult: Decodable, Equatable, Matchable { guard let doc = doc else { return nil } - if !ErrorResult.errorKeys.isDisjoint(with: doc.keys) { - self = .error(ErrorResult(from: doc)) - } else { - self = .document(doc) - } + self = .document(doc) } public init?(from result: BulkWriteResultConvertible?) { @@ -57,12 +54,10 @@ enum TestOperationResult: Decodable, Equatable, Matchable { self = .int(int) } else if let array = try? [BSON](from: decoder) { self = .array(array) + } else if let error = try? ErrorResult(from: decoder) { + self = .error(error) } else if let doc = try? Document(from: decoder) { - if !ErrorResult.errorKeys.isDisjoint(with: doc.keys) { - self = .error(ErrorResult(from: doc)) - } else { - self = .document(doc) - } + self = .document(doc) } else { throw DecodingError.valueNotFound( TestOperationResult.self, @@ -101,8 +96,8 @@ enum TestOperationResult: Decodable, Equatable, Matchable { return array.matches(expected: expectedArray) case let (.document(doc), .document(expectedDoc)): return doc.matches(expected: expectedDoc) - case let (.error(error), .error(expectedError)): - return error.matches(expected: expectedError) + case (.error, .error): + return false default: return false } @@ -155,9 +150,7 @@ extension DeleteResult: BulkWriteResultConvertible { } } -struct ErrorResult: Equatable, Matchable { - internal static let errorKeys: Set = ["errorContains", "errorCodeName", "errorLabelsContain", "errorLabelsOmit"] - +struct ErrorResult: Equatable, Decodable { internal var errorContains: String? internal var errorCodeName: String? @@ -166,14 +159,28 @@ struct ErrorResult: Equatable, Matchable { internal var errorLabelsOmit: [String]? - public init(from doc: Document) { - let errorLabelsContain = doc["errorLabelsContain"]?.arrayValue?.compactMap { $0.stringValue } - let errorLabelsOmit = doc["errorLabelsOmit"]?.arrayValue?.compactMap { $0.stringValue } + private enum CodingKeys: CodingKey { + case errorContains, errorCodeName, errorLabelsContain, errorLabelsOmit + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // None of the error keys must be present themselves, but at least one must. + guard !container.allKeys.isEmpty else { + throw DecodingError.valueNotFound( + ErrorResult.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No results found" + ) + ) + } - self.errorContains = doc["errorContains"]?.stringValue - self.errorCodeName = doc["errorCodeName"]?.stringValue - self.errorLabelsContain = errorLabelsContain?.sorted() - self.errorLabelsOmit = errorLabelsOmit?.sorted() + self.errorContains = try container.decodeIfPresent(String.self, forKey: .errorContains) + self.errorCodeName = try container.decodeIfPresent(String.self, forKey: .errorCodeName) + self.errorLabelsContain = try container.decodeIfPresent([String].self, forKey: .errorLabelsContain) + self.errorLabelsOmit = try container.decodeIfPresent([String].self, forKey: .errorLabelsOmit) } internal static func == (lhs: ErrorResult, rhs: ErrorResult) -> Bool { @@ -184,98 +191,99 @@ struct ErrorResult: Equatable, Matchable { } public func checkErrorResult(_ error: Error) throws { - if let commandError = error as? CommandError { - try self.checkCommandError(commandError) - } else if let writeError = error as? WriteError { - try self.checkWriteError(writeError) - } else if let bulkWriteError = error as? BulkWriteError { - try self.checkBulkWriteError(bulkWriteError) - } else if let logicError = error as? LogicError { - try self.checkLogicError(logicError) - } else if let invalidArgumentError = error as? InvalidArgumentError { - try self.checkInvalidArgumentError(invalidArgumentError) - } else if let connectionError = error as? ConnectionError { - try self.checkConnectionError(connectionError) - } else { - throw TestError(message: "checked ErrorResult with unhandled error \(error)") - } + try self.checkErrorContains(error) + try self.checkCodeName(error) + try self.checkErrorLabels(error) } - internal func checkErrorContains(errorDescription: String) throws { - if let errorContains = self.errorContains { - expect(errorDescription.lowercased()).to(contain(errorContains.lowercased())) + // swiftlint:disable cyclomatic_complexity + + internal func checkErrorContains(_ error: Error) throws { + if let errorContains = self.errorContains?.lowercased() { + if let commandError = error as? CommandError { + expect(commandError.message.lowercased()).to(contain(errorContains)) + } else if let writeError = error as? WriteError { + if let writeFailure = writeError.writeFailure { + expect(writeFailure.message.lowercased()).to(contain(errorContains)) + } + if let writeConcernFailure = writeError.writeConcernFailure { + expect(writeConcernFailure.message.lowercased()).to(contain(errorContains)) + } + } else if let bulkWriteError = error as? BulkWriteError { + if let writeFailures = bulkWriteError.writeFailures { + for writeFailure in writeFailures { + expect(writeFailure.message.lowercased()).to(contain(errorContains)) + } + } + if let writeConcernFailure = bulkWriteError.writeConcernFailure { + expect(writeConcernFailure.message.lowercased()).to(contain(errorContains)) + } + } else if let logicError = error as? LogicError { + expect(logicError.errorDescription.lowercased()).to(contain(errorContains)) + } else if let invalidArgumentError = error as? InvalidArgumentError { + expect(invalidArgumentError.errorDescription.lowercased()).to(contain(errorContains)) + } else if let connectionError = error as? ConnectionError { + expect(connectionError.message.lowercased()).to(contain(errorContains)) + } else { + XCTFail("\(error) does not contain message") + } } } - internal func checkCodeName(codeName: String?) throws { - if let errorCodeName = self.errorCodeName, let codeName = codeName, !codeName.isEmpty { - expect(codeName).to(equal(errorCodeName)) + // swiftlint:enable cyclomatic_complexity + + internal func checkCodeName(_ error: Error) throws { + if let errorCodeName = self.errorCodeName { + if let commandError = error as? CommandError { + expect(commandError.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) + } else if let writeError = error as? WriteError { + if let writeFailure = writeError.writeFailure { + expect(writeFailure.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) + } + if let writeConcernFailure = writeError.writeConcernFailure { + expect(writeConcernFailure.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) + } + } else if let bulkWriteError = error as? BulkWriteError { + if let writeFailures = bulkWriteError.writeFailures { + for writeFailure in writeFailures { + expect(writeFailure.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) + } + } + if let writeConcernFailure = bulkWriteError.writeConcernFailure { + expect(writeConcernFailure.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) + } + } else { + XCTFail("\(error) does not contain codeName") + } } } - internal func checkErrorLabels(errorLabels: [String]?) throws { + internal func checkErrorLabels(_ error: Error) throws { // `configureFailPoint` command correctly handles error labels in MongoDB v4.3.1+ (see SERVER-43941). // Do not check the "RetryableWriteError" error label until the spec test requirements are updated. let skippedErrorLabels = ["RetryableWriteError"] - if let errorLabelsContain = self.errorLabelsContain, let errorLabels = errorLabels { - errorLabelsContain.forEach { label in - if !skippedErrorLabels.contains(label) { - expect(errorLabels).to(contain(label)) - } + if let errorLabelsContain = self.errorLabelsContain { + guard let labeledError = error as? LabeledError else { + XCTFail("\(error) does not contain errorLabels") + return } - } - if let errorLabelsOmit = self.errorLabelsOmit, let errorLabels = errorLabels { - errorLabelsOmit.forEach { label in - expect(errorLabels).toNot(contain(label)) + for label in errorLabelsContain where !skippedErrorLabels.contains(label) { + expect(labeledError.errorLabels).to(contain(label)) } } - } - - internal func checkCommandError(_ error: CommandError) throws { - try self.checkErrorContains(errorDescription: error.message) - try self.checkCodeName(codeName: error.codeName) - try self.checkErrorLabels(errorLabels: error.errorLabels) - } - - internal func checkWriteError(_ error: WriteError) throws { - if let writeFailure = error.writeFailure { - try self.checkErrorContains(errorDescription: writeFailure.message) - try self.checkCodeName(codeName: writeFailure.codeName) - } - if let writeConcernFailure = error.writeConcernFailure { - try self.checkErrorContains(errorDescription: writeConcernFailure.message) - try self.checkCodeName(codeName: writeConcernFailure.codeName) - } - try self.checkErrorLabels(errorLabels: error.errorLabels) - } - internal func checkBulkWriteError(_ error: BulkWriteError) throws { - if let writeFailures = error.writeFailures { - try writeFailures.forEach { writeFailure in - try checkErrorContains(errorDescription: writeFailure.message) - try checkCodeName(codeName: writeFailure.codeName) + if let errorLabelsOmit = self.errorLabelsOmit { + guard let labeledError = error as? LabeledError else { + XCTFail("\(error) does not contain errorLabels") + return + } + if labeledError.errorLabels == nil { + return + } + for label in errorLabelsOmit { + expect(labeledError.errorLabels).toNot(contain(label)) } } - if let writeConcernFailure = error.writeConcernFailure { - try self.checkErrorContains(errorDescription: writeConcernFailure.message) - try self.checkCodeName(codeName: writeConcernFailure.codeName) - } - } - - internal func checkLogicError(_ error: LogicError) throws { - try self.checkErrorContains(errorDescription: error.errorDescription) - // `LogicError` does not have error labels or a code name so there is no need to check them. - } - - internal func checkInvalidArgumentError(_ error: InvalidArgumentError) throws { - try self.checkErrorContains(errorDescription: error.errorDescription) - // `InvalidArgumentError` does not have error labels or a code name so there is no need to check them. - } - - internal func checkConnectionError(_ error: ConnectionError) throws { - try self.checkErrorContains(errorDescription: error.message) - try self.checkErrorLabels(errorLabels: error.errorLabels) - // `ConnectionError` does not have a code name so there is no need to check it. } } From 818f277a50c0451a2ec622c5ba206d50f7b7fb23 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Wed, 8 Apr 2020 17:51:47 -0400 Subject: [PATCH 05/12] Responded to comments 3.0 --- Sources/MongoSwift/ReadPreference.swift | 14 ++------ .../ClientSessionTests.swift | 34 +++++++------------ .../SpecTestRunner/CodableExtensions.swift | 15 ++++++++ Tests/MongoSwiftSyncTests/SyncTestUtils.swift | 13 +++++++ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/Sources/MongoSwift/ReadPreference.swift b/Sources/MongoSwift/ReadPreference.swift index d188124f9..3cab46027 100644 --- a/Sources/MongoSwift/ReadPreference.swift +++ b/Sources/MongoSwift/ReadPreference.swift @@ -3,10 +3,10 @@ import CLibMongoC /// Represents a MongoDB read preference, indicating which member(s) of a replica set read operations should be /// directed to. /// - SeeAlso: https://docs.mongodb.com/manual/reference/read-preference/ -public struct ReadPreference: Decodable { +public struct ReadPreference { /// An enumeration of possible read preference modes. /// - SeeAlso: https://docs.mongodb.com/manual/core/read-preference/#read-preference-modes - public enum Mode: String, Decodable { + public enum Mode: String { /// Default mode. All operations read from the current replica set primary. case primary /// In most situations, operations read from the primary but if it is unavailable, operations read from @@ -92,16 +92,6 @@ public struct ReadPreference: Decodable { /// the least network latency, irrespective of the member’s type. public static let nearest = ReadPreference(.nearest) - private enum CodingKeys: String, CodingKey { - case mode - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let mode = try container.decode(Mode.self, forKey: .mode) - self.init(mode) - } - /** * Initializes a new `ReadPreference` with the mode `primaryPreferred`. With this mode, in most situations * operations read from the primary, but if it is unavailable, operations read from secondary members. diff --git a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift index b075bef25..187e3cf74 100644 --- a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift +++ b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift @@ -1,5 +1,5 @@ import Foundation -@testable import MongoSwift +@testable import class MongoSwift.ClientSession @testable import MongoSwiftSync import Nimble import TestsCommon @@ -7,31 +7,19 @@ import TestsCommon /// Describes an operation run on a collection that takes in a session. struct CollectionSessionOp { let name: String - let body: (MongoSwiftSync.MongoCollection, MongoSwiftSync.ClientSession?) throws -> Void + let body: (MongoCollection, MongoSwiftSync.ClientSession?) throws -> Void } /// Describes an operation run on a database that takes in a session. struct DatabaseSessionOp { let name: String - let body: (MongoSwiftSync.MongoDatabase, MongoSwiftSync.ClientSession?) throws -> Void + let body: (MongoDatabase, MongoSwiftSync.ClientSession?) throws -> Void } /// Describes an operation run on a client that takes in a session. struct ClientSessionOp { let name: String - let body: (MongoSwiftSync.MongoClient, MongoSwiftSync.ClientSession?) throws -> Void -} - -extension MongoSwiftSync.ClientSession { - internal var active: Bool { self.asyncSession.active } - - internal var id: Document? { self.asyncSession.id } - - internal var serverId: Int? { self.asyncSession.serverId } - - internal typealias TransactionState = MongoSwift.ClientSession.TransactionState - - internal var transactionState: TransactionState? { self.asyncSession.transactionState } + let body: (MongoClient, MongoSwiftSync.ClientSession?) throws -> Void } final class SyncClientSessionTests: MongoSwiftTestCase { @@ -111,9 +99,9 @@ final class SyncClientSessionTests: MongoSwiftTestCase { /// iterate over all the different session op types, passing in the provided client/db/collection as needed. func forEachSessionOp( - client: MongoSwiftSync.MongoClient, - database: MongoSwiftSync.MongoDatabase, - collection: MongoSwiftSync.MongoCollection, + client: MongoClient, + database: MongoDatabase, + collection: MongoCollection, _ body: (SessionOp) throws -> Void ) rethrows { try (self.collectionSessionReadOps + self.collectionSessionWriteOps).forEach { op in @@ -242,7 +230,11 @@ final class SyncClientSessionTests: MongoSwiftTestCase { expect(session1.active).to(beFalse()) try self.forEachSessionOp(client: client, database: db, collection: collection) { op in - expect(try op.body(session1)).to(throwError(ClientSession.SessionInactiveError), description: op.name) + expect(try op.body(session1)).to( + throwError( + MongoSwift.ClientSession.SessionInactiveError), + description: op.name + ) } let session2 = client.startSession() @@ -254,7 +246,7 @@ final class SyncClientSessionTests: MongoSwiftTestCase { let cursor = try collection.find(session: session2) expect(cursor.next()).toNot(beNil()) session2.end() - expect(try cursor.next()?.get()).to(throwError(ClientSession.SessionInactiveError)) + expect(try cursor.next()?.get()).to(throwError(MongoSwift.ClientSession.SessionInactiveError)) } /// Sessions spec test 10: Test cursors have the same lsid in the initial find command and in subsequent getMores. diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift index 30bdc2a2d..6f6d107c4 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift @@ -1,3 +1,4 @@ +@testable import struct MongoSwift.ReadPreference import MongoSwiftSync extension DatabaseOptions: Decodable { @@ -62,3 +63,17 @@ extension TransactionOptions: Decodable { case maxCommitTimeMS, readConcern, readPreference, writeConcern } } + +extension ReadPreference.Mode: Decodable {} + +extension ReadPreference: Decodable { + private enum CodingKeys: String, CodingKey { + case mode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let mode = try container.decode(Mode.self, forKey: .mode) + self.init(mode) + } +} diff --git a/Tests/MongoSwiftSyncTests/SyncTestUtils.swift b/Tests/MongoSwiftSyncTests/SyncTestUtils.swift index f0e57b893..6a8f7cc27 100644 --- a/Tests/MongoSwiftSyncTests/SyncTestUtils.swift +++ b/Tests/MongoSwiftSyncTests/SyncTestUtils.swift @@ -1,4 +1,5 @@ import Foundation +@testable import class MongoSwift.ClientSession @testable import MongoSwiftSync import TestsCommon @@ -190,3 +191,15 @@ extension ChangeStream { return nil } } + +extension MongoSwiftSync.ClientSession { + internal var active: Bool { self.asyncSession.active } + + internal var id: Document? { self.asyncSession.id } + + internal var serverId: Int? { self.asyncSession.serverId } + + internal typealias TransactionState = MongoSwift.ClientSession.TransactionState + + internal var transactionState: TransactionState? { self.asyncSession.transactionState } +} From 73296509df23fdc6d6b52acf6f5ecfde386dc68b Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Thu, 9 Apr 2020 15:55:48 -0400 Subject: [PATCH 06/12] Responded to comments 4.0 --- Sources/MongoSwift/ClientSession.swift | 4 ++-- .../MongoCollection+BulkWrite.swift | 23 ++++++++++++------- .../SpecTestRunner/CodableExtensions.swift | 22 +++++++++--------- .../SpecTestRunner/SpecTest.swift | 23 ++++++++++--------- .../SpecTestRunner/TestOperationResult.swift | 5 ++-- Tests/MongoSwiftSyncTests/SyncTestUtils.swift | 2 +- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index 5cf657243..404120369 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -71,12 +71,12 @@ public final class ClientSession { internal var id: Document? /// The server ID of the mongos this session is pinned to. A server ID of 0 indicates that the session is unpinned. - internal var serverId: Int? { + internal var serverId: UInt32? { switch self.state { case .notStarted, .ended: return nil case let .started(session, _): - return Int(mongoc_client_session_get_server_id(session)) + return UInt32(mongoc_client_session_get_server_id(session)) } } diff --git a/Sources/MongoSwift/MongoCollection+BulkWrite.swift b/Sources/MongoSwift/MongoCollection+BulkWrite.swift index 5316ffaec..020298c11 100644 --- a/Sources/MongoSwift/MongoCollection+BulkWrite.swift +++ b/Sources/MongoSwift/MongoCollection+BulkWrite.swift @@ -197,6 +197,14 @@ internal struct BulkWriteOperation: Operation { let opts = try encodeOptions(options: options, session: session) var insertedIds: [Int: BSON] = [:] + if let transactionState = session?.transactionState, transactionState != .none, + self.options?.writeConcern != nil { + throw InvalidArgumentError( + message: "Cannot specify a write concern on an individual helper in a " + + "transaction. Instead specify it when starting the transaction." + ) + } + let (serverId, isAcknowledged): (UInt32, Bool) = try self.collection.withMongocCollection(from: connection) { collPtr in guard let bulk = mongoc_collection_create_bulk_operation_with_opts(collPtr, opts?._bson) else { @@ -216,14 +224,13 @@ internal struct BulkWriteOperation: Operation { var writeConcernAcknowledged: Bool if let transactionState = session?.transactionState, transactionState != .none { - // Bulk write operations cannot have a write concern in a transaction. Default to - // writeConcernAcknowledged = true. Since `libmongoc` returns a null write concern from bulk write - // operations in a transaction, we cannot call `mongoc_bulk_operation_get_write_concern`. - if self.options?.writeConcern != nil { - throw InvalidArgumentError( - message: "Bulk write operations cannot have a write concern in atransaction" - ) - } + // Bulk write operations in transactions must get their write concern from the session, not from + // the `BulkWriteOptions` passed to the `bulkWrite` helper. `libmongoc` surfaces this + // implementation detail by nulling out the write concern stored on the bulk write. To sidestep + // this, we can only call `mongoc_bulk_operation_get_write_concern` out of a transaction. + // + // In a transaction, default to writeConcernAcknowledged = true. This is acceptable because + // transactions do not support unacknowledged writes. writeConcernAcknowledged = true } else { let writeConcern = WriteConcern(from: mongoc_bulk_operation_get_write_concern(bulk)) diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift index 6f6d107c4..d67a33145 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift @@ -4,9 +4,9 @@ import MongoSwiftSync extension DatabaseOptions: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + let readConcern = try container.decodeIfPresent(ReadConcern.self, forKey: .readConcern) + let readPreference = try container.decodeIfPresent(ReadPreference.self, forKey: .readPreference) + let writeConcern = try container.decodeIfPresent(WriteConcern.self, forKey: .writeConcern) self.init(readConcern: readConcern, readPreference: readPreference, writeConcern: writeConcern) } @@ -18,8 +18,8 @@ extension DatabaseOptions: Decodable { extension CollectionOptions: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + let readConcern = try container.decodeIfPresent(ReadConcern.self, forKey: .readConcern) + let writeConcern = try container.decodeIfPresent(WriteConcern.self, forKey: .writeConcern) self.init(readConcern: readConcern, writeConcern: writeConcern) } @@ -31,8 +31,8 @@ extension CollectionOptions: Decodable { extension ClientSessionOptions: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let causalConsistency = try? container.decode(Bool.self, forKey: .causalConsistency) - let defaultTransactionOptions = try? container.decode( + let causalConsistency = try container.decodeIfPresent(Bool.self, forKey: .causalConsistency) + let defaultTransactionOptions = try container.decodeIfPresent( TransactionOptions.self, forKey: .defaultTransactionOptions ) @@ -47,10 +47,10 @@ extension ClientSessionOptions: Decodable { extension TransactionOptions: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let maxCommitTimeMS = try? container.decode(Int64.self, forKey: .maxCommitTimeMS) - let readConcern = try? container.decode(ReadConcern.self, forKey: .readConcern) - let readPreference = try? container.decode(ReadPreference.self, forKey: .readPreference) - let writeConcern = try? container.decode(WriteConcern.self, forKey: .writeConcern) + let maxCommitTimeMS = try container.decodeIfPresent(Int64.self, forKey: .maxCommitTimeMS) + let readConcern = try container.decodeIfPresent(ReadConcern.self, forKey: .readConcern) + let readPreference = try container.decodeIfPresent(ReadPreference.self, forKey: .readPreference) + let writeConcern = try container.decodeIfPresent(WriteConcern.self, forKey: .writeConcern) self.init( maxCommitTimeMS: maxCommitTimeMS, readConcern: readConcern, diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index 0df0c73c0..1e36b169b 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -381,17 +381,18 @@ extension SpecTest { } internal func checkOutcome(dbName: String, collName: String?) throws { - if let outcome = self.outcome { - guard let collName = collName else { - throw TestError(message: "outcome specifies a collection but spec test omits collection name") - } - let client = try MongoClient.makeTestClient() - let verifyColl = client.db(dbName).collection(collName) - let foundDocs = try verifyColl.find().all() - expect(foundDocs.count).to(equal(outcome.collection.data.count)) - zip(foundDocs, outcome.collection.data).forEach { - expect($0).to(sortedEqual($1), description: self.description) - } + guard let outcome = self.outcome else { + return + } + guard let collName = collName else { + throw TestError(message: "outcome specifies a collection but spec test omits collection name") + } + let client = try MongoClient.makeTestClient() + let verifyColl = client.db(dbName).collection(collName) + let foundDocs = try verifyColl.find().all() + expect(foundDocs.count).to(equal(outcome.collection.data.count)) + zip(foundDocs, outcome.collection.data).forEach { + expect($0).to(sortedEqual($1), description: self.description) } } } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift index 97a9e86ed..cfbee3bb0 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift @@ -233,6 +233,7 @@ struct ErrorResult: Equatable, Decodable { // swiftlint:enable cyclomatic_complexity internal func checkCodeName(_ error: Error) throws { + // TODO: can remove `equal("")` references once SERVER-36755 is resolved if let errorCodeName = self.errorCodeName { if let commandError = error as? CommandError { expect(commandError.codeName).to(satisfyAnyOf(equal(errorCodeName), equal(""))) @@ -278,11 +279,11 @@ struct ErrorResult: Equatable, Decodable { XCTFail("\(error) does not contain errorLabels") return } - if labeledError.errorLabels == nil { + guard let errorLabels = labeledError.errorLabels else { return } for label in errorLabelsOmit { - expect(labeledError.errorLabels).toNot(contain(label)) + expect(errorLabels).toNot(contain(label)) } } } diff --git a/Tests/MongoSwiftSyncTests/SyncTestUtils.swift b/Tests/MongoSwiftSyncTests/SyncTestUtils.swift index 6a8f7cc27..0cf8ea668 100644 --- a/Tests/MongoSwiftSyncTests/SyncTestUtils.swift +++ b/Tests/MongoSwiftSyncTests/SyncTestUtils.swift @@ -197,7 +197,7 @@ extension MongoSwiftSync.ClientSession { internal var id: Document? { self.asyncSession.id } - internal var serverId: Int? { self.asyncSession.serverId } + internal var serverId: UInt32? { self.asyncSession.serverId } internal typealias TransactionState = MongoSwift.ClientSession.TransactionState From 7db282f6bfd1726a4263a4d99457c1bf65d90769 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Thu, 9 Apr 2020 18:48:52 -0400 Subject: [PATCH 07/12] Responded to comments 5.0 --- Sources/MongoSwift/ClientSession.swift | 8 ++++++ .../MongoCollection+BulkWrite.swift | 5 ++-- Sources/MongoSwift/MongoError.swift | 25 +++++++++++++++++-- .../SpecTestRunner/SpecTest.swift | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index 404120369..e5e4b1535 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -136,6 +136,14 @@ public final class ClientSession { } } + /// Indicates whether or not the session is in a transaction. + internal var inTransaction: Bool { + if let transactionState = self.transactionState { + return transactionState != .none + } + return false + } + /// The most recent cluster time seen by this session. This value will be nil if either of the following are true: /// - No operations have been executed using this session and `advanceClusterTime` has not been called. /// - This session has been ended. diff --git a/Sources/MongoSwift/MongoCollection+BulkWrite.swift b/Sources/MongoSwift/MongoCollection+BulkWrite.swift index 020298c11..827dd09e1 100644 --- a/Sources/MongoSwift/MongoCollection+BulkWrite.swift +++ b/Sources/MongoSwift/MongoCollection+BulkWrite.swift @@ -197,8 +197,7 @@ internal struct BulkWriteOperation: Operation { let opts = try encodeOptions(options: options, session: session) var insertedIds: [Int: BSON] = [:] - if let transactionState = session?.transactionState, transactionState != .none, - self.options?.writeConcern != nil { + if session?.inTransaction == true && self.options?.writeConcern != nil { throw InvalidArgumentError( message: "Cannot specify a write concern on an individual helper in a " + "transaction. Instead specify it when starting the transaction." @@ -223,7 +222,7 @@ internal struct BulkWriteOperation: Operation { } var writeConcernAcknowledged: Bool - if let transactionState = session?.transactionState, transactionState != .none { + if session?.inTransaction == true { // Bulk write operations in transactions must get their write concern from the session, not from // the `BulkWriteOptions` passed to the `bulkWrite` helper. `libmongoc` surfaces this // implementation detail by nulling out the write concern stored on the bulk write. To sidestep diff --git a/Sources/MongoSwift/MongoError.swift b/Sources/MongoSwift/MongoError.swift index 4afe1f06e..7d8b6ffb3 100644 --- a/Sources/MongoSwift/MongoError.swift +++ b/Sources/MongoSwift/MongoError.swift @@ -165,7 +165,7 @@ public struct WriteConcernFailure: Codable { public let code: ServerErrorCode /// A human-readable string identifying write concern error. - public let codeName: String? + public let codeName: String /// A document identifying the write concern setting related to the error. public let details: Document? @@ -174,7 +174,7 @@ public struct WriteConcernFailure: Codable { public let message: String /// Labels that may describe the context in which this error was thrown. - public let errorLabels: [String]? = nil + public let errorLabels: [String]? private enum CodingKeys: String, CodingKey { case code @@ -183,6 +183,27 @@ public struct WriteConcernFailure: Codable { case message = "errmsg" case errorLabels } + + // TODO: can remove this once SERVER-36755 is resolved + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(ServerErrorCode.self, forKey: .code) + self.message = try container.decode(String.self, forKey: .message) + self.codeName = try container.decodeIfPresent(String.self, forKey: .codeName) ?? "" + self.details = try container.decodeIfPresent(Document.self, forKey: .details) + self.errorLabels = try container.decodeIfPresent([String].self, forKey: .errorLabels) + } + + // TODO: can remove this once SERVER-36755 is resolved + internal init( + code: ServerErrorCode, codeName: String, details: Document?, message: String, errorLabels: [String]? = nil + ) { + self.code = code + self.codeName = codeName + self.message = message + self.details = details + self.errorLabels = errorLabels + } } /// A struct to represent a write error resulting from an executed bulk write. diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index 1e36b169b..dc5e0fbd6 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -7,7 +7,7 @@ import XCTest /// A struct containing the portions of a `CommandStartedEvent` the spec tests use for testing. internal struct TestCommandStartedEvent: Decodable, Matchable { - var command: Document + let command: Document let commandName: String From fc55a4932fa8513bc8c3e6f7a500dfca6cbd2059 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Fri, 10 Apr 2020 09:45:11 -0400 Subject: [PATCH 08/12] Responded to final comments --- Sources/MongoSwift/ClientSession.swift | 2 +- .../RetryableWritesTests.swift | 2 +- .../SpecTestRunner/SpecTest.swift | 9 +- .../SpecTestRunner/TestOperation.swift | 100 +++++++++++------- .../SpecTestRunner/TestOperationResult.swift | 7 -- .../SyncChangeStreamTests.swift | 2 +- .../TransactionsTests.swift | 8 +- 7 files changed, 79 insertions(+), 51 deletions(-) diff --git a/Sources/MongoSwift/ClientSession.swift b/Sources/MongoSwift/ClientSession.swift index e5e4b1535..da8f44a68 100644 --- a/Sources/MongoSwift/ClientSession.swift +++ b/Sources/MongoSwift/ClientSession.swift @@ -76,7 +76,7 @@ public final class ClientSession { case .notStarted, .ended: return nil case let .started(session, _): - return UInt32(mongoc_client_session_get_server_id(session)) + return mongoc_client_session_get_server_id(session) } } diff --git a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift index 70862595d..1ba2afbaf 100644 --- a/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift +++ b/Tests/MongoSwiftSyncTests/RetryableWritesTests.swift @@ -101,7 +101,7 @@ final class RetryableWritesTests: MongoSwiftTestCase, FailPointConfigured { do { result = try test.operation.execute( on: .collection(collection), - sessions: [String: ClientSession]() + sessions: [:] ) } catch { if let bulkError = error as? BulkWriteError { diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index dc5e0fbd6..94f905e33 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -330,7 +330,14 @@ extension SpecTest { let clientOptions = self.clientOptions?.toClientOptions() - let client = try MongoClient.makeTestClient(options: clientOptions) + var singleMongos = true + if let useMultipleMongoses = self.useMultipleMongoses, useMultipleMongoses == true { + singleMongos = false + } + + let client = try MongoClient.makeTestClient( + MongoSwiftTestCase.getConnectionString(singleMongos: singleMongos), options: clientOptions + ) let monitor = client.addCommandMonitor() if let collName = collName { diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift index 8ca43df09..75945c96b 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift @@ -115,7 +115,7 @@ struct TestOperationDescription: Decodable { } target = .session(session) case .testRunner: - target = .testRunner(database) + target = .testRunner } do { @@ -152,8 +152,9 @@ enum TestOperationTarget { /// Execute against the provided session. case session(ClientSession) - /// Execute against the provided test runner. - case testRunner(MongoDatabase) + /// Execute against the provided test runner. Operations that execute on the test runner do not correspond to API + /// methods but instead represent special test operations such as asserts. + case testRunner } /// Protocol describing the behavior of a spec test "operation" @@ -236,6 +237,8 @@ struct AnyTestOperation: Decodable, TestOperation { self.op = try container.decode(AssertSessionUnpinned.self, forKey: .arguments) case "assertSessionTransactionState": self.op = try container.decode(AssertSessionTransactionState.self, forKey: .arguments) + case "targetedFailPoint": + self.op = try container.decode(TargetedFailPoint.self, forKey: .arguments) case "drop": self.op = Drop() case "listDatabaseNames": @@ -260,7 +263,7 @@ struct AnyTestOperation: Decodable, TestOperation { self.op = CommitTransaction() case "abortTransaction": self.op = AbortTransaction() - case "mapReduce", "download_by_name", "download", "count", "targetedFailPoint": + case "mapReduce", "download_by_name", "download", "count": self.op = NotImplemented(name: opName) default: throw TestError(message: "unsupported op name \(opName)") @@ -792,7 +795,7 @@ struct ListDatabaseNames: TestOperation { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabaseNames") } - return try .array(client.listDatabaseNames(session: nil).map { .string($0) }) + return try .array(client.listDatabaseNames().map { .string($0) }) } } @@ -802,7 +805,7 @@ struct ListIndexes: TestOperation { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexes") } - return try TestOperationResult(from: collection.listIndexes(session: nil)) + return try TestOperationResult(from: collection.listIndexes()) } } @@ -812,7 +815,7 @@ struct ListIndexNames: TestOperation { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to listIndexNames") } - return try .array(collection.listIndexNames(session: nil).map { .string($0) }) + return try .array(collection.listIndexNames().map { .string($0) }) } } @@ -822,7 +825,7 @@ struct ListDatabases: TestOperation { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") } - return try TestOperationResult(from: client.listDatabases(session: nil)) + return try TestOperationResult(from: client.listDatabases()) } } @@ -832,7 +835,7 @@ struct ListMongoDatabases: TestOperation { guard case let .client(client) = target else { throw TestError(message: "client not provided to listDatabases") } - _ = try client.listMongoDatabases(session: nil) + _ = try client.listMongoDatabases() return nil } } @@ -843,7 +846,7 @@ struct ListCollections: TestOperation { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollections") } - return try TestOperationResult(from: database.listCollections(session: nil)) + return try TestOperationResult(from: database.listCollections()) } } @@ -853,7 +856,7 @@ struct ListMongoCollections: TestOperation { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionObjects") } - _ = try database.listMongoCollections(session: nil) + _ = try database.listMongoCollections() return nil } } @@ -864,7 +867,7 @@ struct ListCollectionNames: TestOperation { guard case let .database(database) = target else { throw TestError(message: "database not provided to listCollectionNames") } - return try .array(database.listCollectionNames(session: nil).map { .string($0) }) + return try .array(database.listCollectionNames().map { .string($0) }) } } @@ -873,13 +876,15 @@ struct Watch: TestOperation { throws -> TestOperationResult? { switch target { case let .client(client): - _ = try client.watch(session: nil) + _ = try client.watch() case let .database(database): - _ = try database.watch(session: nil) + _ = try database.watch() case let .collection(collection): - _ = try collection.watch(session: nil) - case .session, .testRunner: - break + _ = try collection.watch() + case .session: + throw TestError(message: "watch cannot be executed on a session") + case .testRunner: + throw TestError(message: "watch cannot be executed on the test runner") } return nil } @@ -891,7 +896,7 @@ struct EstimatedDocumentCount: TestOperation { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to estimatedDocumentCount") } - return try .int(collection.estimatedDocumentCount(session: nil)) + return try .int(collection.estimatedDocumentCount()) } } @@ -907,7 +912,7 @@ struct StartTransaction: TestOperation { guard case let .session(session) = target else { throw TestError(message: "session not provided to startTransaction") } - _ = try session.startTransaction(options: self.options) + try session.startTransaction(options: self.options) return nil } } @@ -1000,11 +1005,11 @@ struct AssertCollectionExists: TestOperation { func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { - guard case let .testRunner(database) = target else { - throw TestError(message: "database not provided to assertCollectionExists") + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertCollectionExists") } let client = try MongoClient.makeTestClient() - let collectionNames = try client.db(database.name).listCollectionNames(session: nil) + let collectionNames = try client.db(self.database).listCollectionNames() expect(collectionNames).to(contain(self.collection)) return nil } @@ -1016,11 +1021,11 @@ struct AssertCollectionNotExists: TestOperation { func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { - guard case let .testRunner(database) = target else { - throw TestError(message: "database not provided to assertCollectionNotExists") + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertCollectionNotExists") } let client = try MongoClient.makeTestClient() - let collectionNames = try client.db(database.name).listCollectionNames(session: nil) + let collectionNames = try client.db(self.database).listCollectionNames() expect(collectionNames).toNot(contain(self.collection)) return nil } @@ -1033,11 +1038,11 @@ struct AssertIndexExists: TestOperation { func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { - guard case let .testRunner(database) = target else { - throw TestError(message: "database not provided to assertIndexExists") + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertIndexExists") } let client = try MongoClient.makeTestClient() - let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) + let indexNames = try client.db(self.database).collection(self.collection).listIndexNames() expect(indexNames).to(contain(self.index)) return nil } @@ -1050,11 +1055,11 @@ struct AssertIndexNotExists: TestOperation { func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { - guard case let .testRunner(database) = target else { - throw TestError(message: "database not provided to assertIndexNotExists") + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertIndexNotExists") } let client = try MongoClient.makeTestClient() - let indexNames = try client.db(database.name).collection(self.collection).listIndexNames(session: nil) + let indexNames = try client.db(self.database).collection(self.collection).listIndexNames() expect(indexNames).toNot(contain(self.index)) return nil } @@ -1063,11 +1068,14 @@ struct AssertIndexNotExists: TestOperation { struct AssertSessionPinned: TestOperation { let session: String? - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertSessionPinned") + } guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") } - expect(serverId).to(equal(0)) + expect(serverId).toNot(equal(0)) return nil } } @@ -1075,11 +1083,14 @@ struct AssertSessionPinned: TestOperation { struct AssertSessionUnpinned: TestOperation { let session: String? - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertSessionUnpinned") + } guard let serverId = sessions[self.session ?? ""]?.serverId else { throw TestError(message: "active session not provided to assertSessionPinned") } - expect(serverId).toNot(equal(0)) + expect(serverId).to(equal(0)) return nil } } @@ -1088,7 +1099,10 @@ struct AssertSessionTransactionState: TestOperation { let session: String? let state: ClientSession.TransactionState - func execute(on _: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to assertSessionTransactionState") + } guard let transactionState = sessions[self.session ?? ""]?.transactionState else { throw TestError(message: "active session not provided to assertSessionTransactionState") } @@ -1097,6 +1111,20 @@ struct AssertSessionTransactionState: TestOperation { } } +struct TargetedFailPoint: TestOperation { + let session: String? + let failPoint: Document + + func execute(on target: TestOperationTarget, sessions _: [String: ClientSession]) throws -> TestOperationResult? { + guard case .testRunner = target else { + throw TestError(message: "test runner not provided to targetedFailPoint") + } + let client = try MongoClient.makeTestClient() + try client.db("admin").runCommand(self.failPoint) + return nil + } +} + /// Dummy `TestOperation` that can be used in place of an unimplemented one (e.g. findOne) struct NotImplemented: TestOperation { internal let name: String diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift index cfbee3bb0..d578428c6 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperationResult.swift @@ -183,13 +183,6 @@ struct ErrorResult: Equatable, Decodable { self.errorLabelsOmit = try container.decodeIfPresent([String].self, forKey: .errorLabelsOmit) } - internal static func == (lhs: ErrorResult, rhs: ErrorResult) -> Bool { - lhs.errorContains == rhs.errorContains && - lhs.errorCodeName == rhs.errorCodeName && - lhs.errorLabelsContain == rhs.errorLabelsContain && - lhs.errorLabelsOmit == rhs.errorLabelsOmit - } - public func checkErrorResult(_ error: Error) throws { try self.checkErrorContains(error) try self.checkCodeName(error) diff --git a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift index 2d1455c2e..cf70bc2a5 100644 --- a/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift +++ b/Tests/MongoSwiftSyncTests/SyncChangeStreamTests.swift @@ -69,7 +69,7 @@ internal struct ChangeStreamTestOperation: Decodable { internal func execute(using client: MongoClient) throws -> TestOperationResult? { let db = client.db(self.database) let coll = db.collection(self.collection) - return try self.operation.execute(on: .collection(coll), sessions: [String: ClientSession]()) + return try self.operation.execute(on: .collection(coll), sessions: [:]) } } diff --git a/Tests/MongoSwiftSyncTests/TransactionsTests.swift b/Tests/MongoSwiftSyncTests/TransactionsTests.swift index 927dd005c..d26545e86 100644 --- a/Tests/MongoSwiftSyncTests/TransactionsTests.swift +++ b/Tests/MongoSwiftSyncTests/TransactionsTests.swift @@ -58,10 +58,10 @@ final class TransactionsTests: MongoSwiftTestCase, FailPointConfigured { func testTransactions() throws { let skippedTestKeywords = [ - "count", // skipped in RetryableReadsTests.swift - "mongos-pin-auto", // useMultipleMongoses, targetedFailPoint not implemented - "mongos-recovery-token", // useMultipleMongoses, targetedFailPoint not implemented - "pin-mongos", // useMultipleMongoses, targetedFailPoint not implemented + "count", // old count API was deprecated before MongoDB 4.0 and is not supported by the driver + "mongos-pin-auto", // TODO: see SWIFT-774 + "mongos-recovery-token", // TODO: see SWIFT-774 + "pin-mongos", // TODO: see SWIFT-774 "retryable-abort-errorLabels", // requires libmongoc v1.17 (see SWIFT-762) "retryable-commit-errorLabels" // requires libmongoc v1.17 (see SWIFT-762) ] From 7aa596fa144b66ae327e76db6255b0c7e8ef51d3 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Fri, 10 Apr 2020 09:45:11 -0400 Subject: [PATCH 09/12] Responded to final comments --- Sources/MongoSwift/MongoClient.swift | 10 ++-- Sources/MongoSwift/MongoCollection+Read.swift | 12 ++--- .../MongoSwiftSync/MongoCollection+Read.swift | 11 ++--- .../ClientSessionTests.swift | 4 +- .../RetryableReadsTests.swift | 2 +- .../SpecTestRunner/CodableExtensions.swift | 22 +++++++++ .../SpecTestRunner/SpecTest.swift | 49 +++---------------- .../SpecTestRunner/TestOperation.swift | 30 +----------- .../TransactionsTests.swift | 2 +- 9 files changed, 48 insertions(+), 94 deletions(-) diff --git a/Sources/MongoSwift/MongoClient.swift b/Sources/MongoSwift/MongoClient.swift index 56383c429..668be6451 100644 --- a/Sources/MongoSwift/MongoClient.swift +++ b/Sources/MongoSwift/MongoClient.swift @@ -3,16 +3,16 @@ import NIO import NIOConcurrencyHelpers /// Options to use when creating a `MongoClient`. -public struct ClientOptions: CodingStrategyProvider, Decodable { +public struct ClientOptions: CodingStrategyProvider { // swiftlint:disable redundant_optional_initialization /// Specifies the `DataCodingStrategy` to use for BSON encoding/decoding operations performed by this client and any /// databases or collections that derive from it. - public var dataCodingStrategy: DataCodingStrategy? = nil + public var dataCodingStrategy: DataCodingStrategy? /// Specifies the `DateCodingStrategy` to use for BSON encoding/decoding operations performed by this client and any /// databases or collections that derive from it. - public var dateCodingStrategy: DateCodingStrategy? = nil + public var dateCodingStrategy: DateCodingStrategy? /// The maximum number of connections that may be associated with a connection pool created by this client at a /// given time. This includes in-use and available connections. Defaults to 100. @@ -22,7 +22,7 @@ public struct ClientOptions: CodingStrategyProvider, Decodable { public var readConcern: ReadConcern? /// Specifies a ReadPreference to use for the client. - public var readPreference: ReadPreference? = nil + public var readPreference: ReadPreference? /// Determines whether the client should retry supported read operations (on by default). public var retryReads: Bool? @@ -65,7 +65,7 @@ public struct ClientOptions: CodingStrategyProvider, Decodable { /// Specifies the `UUIDCodingStrategy` to use for BSON encoding/decoding operations performed by this client and any /// databases or collections that derive from it. - public var uuidCodingStrategy: UUIDCodingStrategy? = nil + public var uuidCodingStrategy: UUIDCodingStrategy? // swiftlint:enable redundant_optional_initialization diff --git a/Sources/MongoSwift/MongoCollection+Read.swift b/Sources/MongoSwift/MongoCollection+Read.swift index 210a5b7e7..cdf947a15 100644 --- a/Sources/MongoSwift/MongoCollection+Read.swift +++ b/Sources/MongoSwift/MongoCollection+Read.swift @@ -114,11 +114,11 @@ extension MongoCollection { } /** - * Gets an estimate of the count of documents in this collection using collection metadata. + * Gets an estimate of the count of documents in this collection using collection metadata. This operation cannot + * be used in a transaction. * * - Parameters: * - options: Optional `EstimatedDocumentCountOptions` to use when executing the command - * - session: Optional `ClientSession` to use when executing this command * * - Returns: * An `EventLoopFuture`. On success, contains an estimate of the count of documents in this collection. @@ -126,16 +126,12 @@ extension MongoCollection { * If the future fails, the error is likely one of the following: * - `CommandError` if an error occurs that prevents the command from executing. * - `InvalidArgumentError` if the options passed in form an invalid combination. - * - `LogicError` if the provided session is inactive. * - `LogicError` if this collection's parent client has already been closed. * - `EncodingError` if an error occurs while encoding the options to BSON. */ - public func estimatedDocumentCount( - options: EstimatedDocumentCountOptions? = nil, - session: ClientSession? = nil - ) -> EventLoopFuture { + public func estimatedDocumentCount(options: EstimatedDocumentCountOptions? = nil) -> EventLoopFuture { let operation = EstimatedDocumentCountOperation(collection: self, options: options) - return self._client.operationExecutor.execute(operation, client: self._client, session: session) + return self._client.operationExecutor.execute(operation, client: self._client, session: nil) } /** diff --git a/Sources/MongoSwiftSync/MongoCollection+Read.swift b/Sources/MongoSwiftSync/MongoCollection+Read.swift index db055f658..854595162 100644 --- a/Sources/MongoSwiftSync/MongoCollection+Read.swift +++ b/Sources/MongoSwiftSync/MongoCollection+Read.swift @@ -94,19 +94,16 @@ extension MongoCollection { } /** - * Gets an estimate of the count of documents in this collection using collection metadata. + * Gets an estimate of the count of documents in this collection using collection metadata. This operation cannot + * be used in a transaction. * * - Parameters: * - options: Optional `EstimatedDocumentCountOptions` to use when executing the command - * - session: Optional `ClientSession` to use when executing this command * * - Returns: an estimate of the count of documents in this collection */ - public func estimatedDocumentCount( - options: EstimatedDocumentCountOptions? = nil, - session: ClientSession? = nil - ) throws -> Int { - try self.asyncColl.estimatedDocumentCount(options: options, session: session?.asyncSession).wait() + public func estimatedDocumentCount(options: EstimatedDocumentCountOptions? = nil) throws -> Int { + try self.asyncColl.estimatedDocumentCount(options: options).wait() } /** diff --git a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift index 187e3cf74..957b5c3a3 100644 --- a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift +++ b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift @@ -44,7 +44,9 @@ final class SyncClientSessionTests: MongoSwiftTestCase { CollectionSessionOp(name: "aggregate") { _ = try $0.aggregate([], session: $1).next()?.get() }, CollectionSessionOp(name: "distinct") { _ = try $0.distinct(fieldName: "x", session: $1) }, CollectionSessionOp(name: "countDocuments") { _ = try $0.countDocuments(session: $1) }, - CollectionSessionOp(name: "estimatedDocumentCount") { _ = try $0.estimatedDocumentCount(session: $1) } + CollectionSessionOp(name: "estimatedDocumentCount") { collection, _ in + _ = try collection.estimatedDocumentCount() + } ] // list of write operations on MongoCollection that take in a session diff --git a/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift b/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift index 52eaa6570..0e58eea5d 100644 --- a/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift +++ b/Tests/MongoSwiftSyncTests/RetryableReadsTests.swift @@ -9,7 +9,7 @@ private struct RetryableReadsTest: SpecTest { let operations: [TestOperationDescription] - let clientOptions: TestClientOptions? + let clientOptions: ClientOptions? let useMultipleMongoses: Bool? diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift index d67a33145..9d09f3bd3 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/CodableExtensions.swift @@ -77,3 +77,25 @@ extension ReadPreference: Decodable { self.init(mode) } } + +extension ClientOptions: Decodable { + private enum CodingKeys: String, CodingKey { + case retryReads, retryWrites, w, readConcernLevel, mode = "readPreference" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let readConcern = try? ReadConcern(container.decode(String.self, forKey: .readConcernLevel)) + let readPreference = try? ReadPreference(container.decode(ReadPreference.Mode.self, forKey: .mode)) + let retryReads = try container.decodeIfPresent(Bool.self, forKey: .retryReads) + let retryWrites = try container.decodeIfPresent(Bool.self, forKey: .retryWrites) + let writeConcern = try? WriteConcern(w: container.decode(WriteConcern.W.self, forKey: .w)) + self.init( + readConcern: readConcern, + readPreference: readPreference, + retryReads: retryReads, + retryWrites: retryWrites, + writeConcern: writeConcern + ) + } +} diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index 94f905e33..e09159e28 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -21,13 +21,13 @@ internal struct TestCommandStartedEvent: Decodable, Matchable { case type = "command_started_event" } - internal init(from event: CommandStartedEvent, sessionIds: [String: Document]? = nil) { + internal init(from event: CommandStartedEvent, sessionIds: [Document: String]? = nil) { var command = event.command // If command started event has "lsid": Document(...), change the value to correpond to "session0", // "session1", etc. if let sessionIds = sessionIds, let sessionDoc = command["lsid"]?.documentValue { - for (sessionName, sessionId) in sessionIds where sessionId == sessionDoc { + for (sessionId, sessionName) in sessionIds where sessionId == sessionDoc { command["lsid"] = .string(sessionName) } } @@ -132,41 +132,6 @@ internal enum TestData: Decodable { } } -public struct TestClientOptions: Decodable { - var readConcern: ReadConcern? - - var readPreference: ReadPreference? - - var retryReads: Bool? - - var retryWrites: Bool? - - var writeConcern: WriteConcern? - - private enum CodingKeys: String, CodingKey { - case retryReads, retryWrites, w, readConcernLevel, mode = "readPreference" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.readConcern = try? ReadConcern(container.decode(String.self, forKey: .readConcernLevel)) - self.readPreference = try? ReadPreference(container.decode(ReadPreference.Mode.self, forKey: .mode)) - self.retryReads = try? container.decode(Bool.self, forKey: .retryReads) - self.retryWrites = try? container.decode(Bool.self, forKey: .retryWrites) - self.writeConcern = try? WriteConcern(w: container.decode(WriteConcern.W.self, forKey: .w)) - } - - public func toClientOptions() -> ClientOptions { - ClientOptions( - readConcern: self.readConcern, - readPreference: self.readPreference, - retryReads: self.retryReads, - retryWrites: self.retryWrites, - writeConcern: self.writeConcern - ) - } -} - /// Struct representing the contents of a collection after a spec test has been run. internal struct CollectionTestInfo: Decodable { /// An optional name specifying a collection whose documents match the `data` field of this struct. @@ -276,7 +241,7 @@ internal protocol SpecTest: Decodable { var description: String { get } /// Options used to configure the `MongoClient` used for this test. - var clientOptions: TestClientOptions? { get } + var clientOptions: ClientOptions? { get } /// If true, the `MongoClient` for this test should be initialized with multiple mongos seed addresses. /// If false or omitted, only a single mongos address should be specified. @@ -328,15 +293,13 @@ extension SpecTest { print("Executing test: \(self.description)") - let clientOptions = self.clientOptions?.toClientOptions() - var singleMongos = true if let useMultipleMongoses = self.useMultipleMongoses, useMultipleMongoses == true { singleMongos = false } let client = try MongoClient.makeTestClient( - MongoSwiftTestCase.getConnectionString(singleMongos: singleMongos), options: clientOptions + MongoSwiftTestCase.getConnectionString(singleMongos: singleMongos), options: self.clientOptions ) let monitor = client.addCommandMonitor() @@ -357,7 +320,7 @@ extension SpecTest { sessions[session] = client.startSession(options: self.sessionOptions?[session]) } - var sessionIds = [String: Document]() + var sessionIds = [Document: String]() try monitor.captureEvents { for operation in self.operations { @@ -371,7 +334,7 @@ extension SpecTest { // Keep track of the session IDs assigned to each session. // Deinitialize each session thereby implicitly ending them. for session in sessions.keys { - sessionIds[session] = sessions[session]?.id + sessionIds[sessions[session]?.id ?? Document()] = session sessions[session] = nil } } diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift index 75945c96b..f44523b5d 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/TestOperation.swift @@ -513,14 +513,6 @@ struct InsertOne: TestOperation { let session: String? let document: Document - private enum CodingKeys: String, CodingKey { case session, document } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.document = try container.decode(Document.self, forKey: .document) - } - func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { throw TestError(message: "collection not provided to insertOne") @@ -532,16 +524,7 @@ struct InsertOne: TestOperation { struct InsertMany: TestOperation { let session: String? let documents: [Document] - let options: InsertManyOptions - - private enum CodingKeys: String, CodingKey { case session, documents } - - init(from decoder: Decoder) throws { - self.options = (try? InsertManyOptions(from: decoder)) ?? InsertManyOptions() - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.documents = try container.decode([Document].self, forKey: .documents) - } + let options: InsertManyOptions? func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { @@ -621,16 +604,7 @@ extension WriteModel: Decodable { struct BulkWrite: TestOperation { let session: String? let requests: [WriteModel] - let options: BulkWriteOptions - - private enum CodingKeys: CodingKey { case session, requests } - - init(from decoder: Decoder) throws { - self.options = (try? BulkWriteOptions(from: decoder)) ?? BulkWriteOptions() - let container = try decoder.container(keyedBy: CodingKeys.self) - self.session = try container.decodeIfPresent(String.self, forKey: .session) - self.requests = try container.decode([WriteModel].self, forKey: .requests) - } + let options: BulkWriteOptions? func execute(on target: TestOperationTarget, sessions: [String: ClientSession]) throws -> TestOperationResult? { guard case let .collection(collection) = target else { diff --git a/Tests/MongoSwiftSyncTests/TransactionsTests.swift b/Tests/MongoSwiftSyncTests/TransactionsTests.swift index d26545e86..b7946f093 100644 --- a/Tests/MongoSwiftSyncTests/TransactionsTests.swift +++ b/Tests/MongoSwiftSyncTests/TransactionsTests.swift @@ -15,7 +15,7 @@ private struct TransactionsTest: SpecTest { let useMultipleMongoses: Bool? - let clientOptions: TestClientOptions? + let clientOptions: ClientOptions? let failPoint: FailPoint? From 26c9fcfcc88c27c6e4b3292f090101dd7d7b5cf7 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Fri, 10 Apr 2020 13:54:54 -0400 Subject: [PATCH 10/12] Fixed lint issues --- Sources/MongoSwift/MongoClient.swift | 2 -- Sources/MongoSwift/MongoSwiftVersion.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/Sources/MongoSwift/MongoClient.swift b/Sources/MongoSwift/MongoClient.swift index 668be6451..7268b381f 100644 --- a/Sources/MongoSwift/MongoClient.swift +++ b/Sources/MongoSwift/MongoClient.swift @@ -4,8 +4,6 @@ import NIOConcurrencyHelpers /// Options to use when creating a `MongoClient`. public struct ClientOptions: CodingStrategyProvider { - // swiftlint:disable redundant_optional_initialization - /// Specifies the `DataCodingStrategy` to use for BSON encoding/decoding operations performed by this client and any /// databases or collections that derive from it. public var dataCodingStrategy: DataCodingStrategy? diff --git a/Sources/MongoSwift/MongoSwiftVersion.swift b/Sources/MongoSwift/MongoSwiftVersion.swift index 318684e9d..152a6f17c 100644 --- a/Sources/MongoSwift/MongoSwiftVersion.swift +++ b/Sources/MongoSwift/MongoSwiftVersion.swift @@ -1,6 +1,4 @@ // Generated using Sourcery 0.16.1 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT - -// swiftlint:disable:previous vertical_whitespace internal let MongoSwiftVersionString = "1.0.0-rc0" From 1306948970bfd397571174e0625ab4f544134e20 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Fri, 10 Apr 2020 14:07:57 -0400 Subject: [PATCH 11/12] Removed estimatedDocumentCount from ClientSessionTests --- Tests/MongoSwiftSyncTests/ClientSessionTests.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift index 957b5c3a3..6afd8c6fe 100644 --- a/Tests/MongoSwiftSyncTests/ClientSessionTests.swift +++ b/Tests/MongoSwiftSyncTests/ClientSessionTests.swift @@ -43,10 +43,7 @@ final class SyncClientSessionTests: MongoSwiftTestCase { CollectionSessionOp(name: "findOne") { _ = try $0.findOne([:], session: $1) }, CollectionSessionOp(name: "aggregate") { _ = try $0.aggregate([], session: $1).next()?.get() }, CollectionSessionOp(name: "distinct") { _ = try $0.distinct(fieldName: "x", session: $1) }, - CollectionSessionOp(name: "countDocuments") { _ = try $0.countDocuments(session: $1) }, - CollectionSessionOp(name: "estimatedDocumentCount") { collection, _ in - _ = try collection.estimatedDocumentCount() - } + CollectionSessionOp(name: "countDocuments") { _ = try $0.countDocuments(session: $1) } ] // list of write operations on MongoCollection that take in a session From f4515a475321af7b720535fe6e2076b85c915467 Mon Sep 17 00:00:00 2001 From: James Heppenstall Date: Fri, 10 Apr 2020 16:40:57 -0400 Subject: [PATCH 12/12] Responded to Patrick's final comment --- Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift index e09159e28..564b16243 100644 --- a/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift +++ b/Tests/MongoSwiftSyncTests/SpecTestRunner/SpecTest.swift @@ -334,7 +334,7 @@ extension SpecTest { // Keep track of the session IDs assigned to each session. // Deinitialize each session thereby implicitly ending them. for session in sessions.keys { - sessionIds[sessions[session]?.id ?? Document()] = session + if let sessionId = sessions[session]?.id { sessionIds[sessionId] = session } sessions[session] = nil } }