diff --git a/README.md b/README.md index 5393f01..b443c10 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CoreDataRepository [![CI](https://github.com/roanutil/CoreDataRepository/actions/workflows/ci.yml/badge.svg)](https://github.com/roanutil/CoreDataRepository/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/roanutil/CoreDataRepository/branch/main/graph/badge.svg?token=WRO4CXYWRG)](https://codecov.io/gh/roanutil/CoreDataRepository) +[![codecov](https://codecov.io/gh/roanutil/CoreDataRepository/branch/main/graph/badge.svg?token=WRO4CXYWRG)](https://codecov.io/gh/roanutil/CoreDataRepository) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/roanutil/CoreDataRepository) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/roanutil/CoreDataRepository) @@ -40,9 +40,21 @@ To give some weight to this idea, here's a quote from the Q&A portion of [this]( ### Model Bridging -There are two protocols that handle bridging between the value type and managed models. +There are various protocols for defining how a value type should +bridge to the corresponding `NSManagedObject` subclass. Each +protocol is intended for a general pattern of use. -#### RepositoryManagedModel +A single value type can conform to multiple protocols to combine their supported functionality. A single `NSManagedObject` subclass can be bridged to by multiple value types. + +- `FetchableUnmanagedModel` for types that will be queried through 'fetch' endpoints. +- `ReadableUnmanagedModel` for types that can be accessed individually. Inherits from `FetchableUnmanagedModel`. + - `IdentifiedUnmanagedModel` for `ReadableUnmanagedModel` types that have a unique, hashable ID value. + - `ManagedIdReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID`. + - `ManagedIdUrlReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID` in `URL` form. +- `WritableUnmanagedModel` for that types that need to write to the store via create, update, and delete operations. +- `UnmanagedModel` for types that conform to both `ReadableUnmanagedModel` and `WritableUnmanagedModel`. + +#### UnmanagedModel ```swift @objc(ManagedMovie) @@ -50,64 +62,39 @@ public final class ManagedMovie: NSManagedObject { @NSManaged var id: UUID? @NSManaged var title: String? @NSManaged var releaseDate: Date? - @NSManaged var boxOffice: NSDecimalNumber? -} - -extension ManagedMovie: RepositoryManagedModel { - public func create(from unmanaged: Movie) { - update(from: unmanaged) - } - - public typealias Unmanaged = Movie - public var asUnmanaged: Movie { - Movie( - id: id ?? UUID(), - title: title ?? "", - releaseDate: releaseDate ?? Date(), - boxOffice: (boxOffice ?? 0) as Decimal, - url: objectID.uriRepresentation() - ) - } - - public func update(from unmanaged: Movie) { - id = unmanaged.id - title = unmanaged.title - releaseDate = unmanaged.releaseDate - boxOffice = NSDecimalNumber(decimal: unmanaged.boxOffice) - } + @NSManaged var boxOffice: Decimal? } -``` - -#### UnmanagedModel -```swift -public struct Movie: Hashable { +public struct Movie: Equatable, ManagedIdUrlReferencable, Sendable { public let id: UUID public var title: String = "" public var releaseDate: Date public var boxOffice: Decimal = 0 - public var url: URL? + public var managedIdUrl: URL? } -extension Movie: UnmanagedModel { - public var managedRepoUrl: URL? { - get { - url - } - set(newValue) { - url = newValue - } +extension Movie: FetchableUnmanagedModel { + public init(managed: ManagedMovie) { + self.id = managed.id + self.title = managed.title + self.releaseDate = managed.releaseDate + self.boxOffice = managed.boxOffice + self.managedIdUrl = managed.objectID.uriRepresentation() } +} - public func asManagedModel(in context: NSManagedObjectContext) -> ManagedMovie { - let object = ManagedMovie(context: context) - object.id = id - object.title = title - object.releaseDate = releaseDate - object.boxOffice = boxOffice as NSDecimalNumber - return object +extension Movie: ReadableUnmanagedModel {} + +extension Movie: WritableUnmanagedModel { + public func updating(managed: ManagedMovie) throws { + managed.id = id + managed.title = title + managed.releaseDate = releaseDate + managed.boxOffice = boxOffice } } + +extension Movie: UnmanagedModel {} ``` ### CRUD @@ -134,24 +121,13 @@ if case let .success(movies) = result { ### Fetch Subscription -Similar to a regular fe: +Similar to a regular fetch: ```swift -let result: AnyPublisher<[Movie], CoreDataError> = repository.fetchSubscription(fetchRequest) -let cancellable = result.subscribe(on: userInitSerialQueue) - .receive(on: mainQueue) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - os_log("Fetched a bunch of movies") - default: - fatalError("Failed to fetch all the movies!") - } - }, receiveValue: { value in - os_log("Fetched \(value.items.count) movies") - }) -... -cancellable.cancel() +let stream: AsyncThrowingStream<[Movie], any Error> = repository.fetchThrowingSubscription(fetchRequest) +for try await movies in stream { + os_log("Fetched \(movies.count) movies") +} ``` ### Aggregate @@ -185,7 +161,7 @@ let result: Result = await repository.insert #### OR ```swift -let movies: [[String: Any]] = [ +let movies: [Movie] = [ Movie(id: UUID(), title: "A", releaseDate: Date()), Movie(id: UUID(), title: "B", releaseDate: Date()), Movie(id: UUID(), title: "C", releaseDate: Date()), @@ -197,15 +173,58 @@ os_log("Created these movies: \(result.success)") os_log("Failed to create these movies: \(result.failed)") ``` -## TODO +### Transactions -- Add a subscription feature for aggregate functions -- Migrate subscription endpoints to AsyncSequence instead of Publisher -- Simplify model protocols (require only one protocol for the value type) -- Allow older platform support by working around the newer variants of `NSManagedObjectContext.perform` and `NSManagedObjectContext.performAndWait` +Use `withTransaction` to group multiple operations together atomically: + +```swift +let newMovies = [ + Movie(id: UUID(), title: "Movie A", releaseDate: Date(), boxOffice: 1000), + Movie(id: UUID(), title: "Movie B", releaseDate: Date(), boxOffice: 2000) +] + +// All operations within the transaction will succeed or fail together +let result = try await repository.withTransaction(transactionAuthor: "BulkMovieImport") { transaction in + var createdMovies: [Movie] = [] + + for movie in newMovies { + let createResult = try await repository.create(movie).get() + createdMovies.append(createResult) + } + + // Update existing movie + let fetchRequest = Movie.managedFetchRequest + fetchRequest.predicate = NSPredicate(format: "title == %@", "Old Movie") + if let existingMovie = try await repository.fetch(fetchRequest).get().first { + var updatedMovie = existingMovie + updatedMovie.boxOffice = 5000 + _ = try await repository.update(updatedMovie).get() + } + + return createdMovies +} + +os_log("Transaction completed with \(result.count) new movies") +``` + +**Important:** When using batch operations within transactions, don't specify `transactionAuthor` for individual operations as it's handled at the transaction level: + +```swift +// ✅ Correct - transactionAuthor only on withTransaction +try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in + let request = NSBatchUpdateRequest(entityName: "ManagedMovie") + request.propertiesToUpdate = ["boxOffice": 0] + return await repository.update(request) // No transactionAuthor here +} + +// ❌ Incorrect - don't specify transactionAuthor on both +try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in + let request = NSBatchUpdateRequest(entityName: "ManagedMovie") + request.propertiesToUpdate = ["boxOffice": 0] + return await repository.update(request, transactionAuthor: "BatchUpdate") // Ignored +} +``` ## Contributing I welcome any feedback or contributions. It's probably best to create an issue where any possible changes can be discussed before doing the work and creating a PR. - -The above [TODO](#todo) section is a good place to start if you would like to contribute but don't already have a change in mind. diff --git a/Sources/CoreDataRepository/CoreDataError.swift b/Sources/CoreDataRepository/CoreDataError.swift index 7bc10d9..a291cd3 100644 --- a/Sources/CoreDataRepository/CoreDataError.swift +++ b/Sources/CoreDataRepository/CoreDataError.swift @@ -124,6 +124,19 @@ public enum CoreDataError: Error, Hashable, Sendable { ) } } + + @usableFromInline + static func catching(block: () async throws -> T) async throws(Self) -> T { + do { + return try await block() + } catch let error as CoreDataError { + throw error + } catch let error as CocoaError { + throw .cocoa(error) + } catch { + throw .unknown(error as NSError) + } + } } extension CoreDataError: CustomNSError { diff --git a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift index d4bcb92..637429f 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift @@ -33,7 +33,7 @@ extension CoreDataRepository { default: await Self.send( function: function, - context: context, + context: Transaction.current?.context ?? context, predicate: predicate, entityDesc: entityDesc, attributeDesc: attributeDesc, @@ -55,7 +55,7 @@ extension CoreDataRepository { ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .average, - context: context, + context: Transaction.current?.context ?? context, predicate: predicate, entityDesc: entityDesc, attributeDesc: attributeDesc, @@ -124,7 +124,7 @@ extension CoreDataRepository { entityDesc: NSEntityDescription, as _: Value.Type ) async -> Result where Value: Numeric, Value: Sendable { - await context.performInScratchPad { scratchPad in + await context.performInChild { scratchPad in do { let request = try NSFetchRequest .countRequest(predicate: predicate, entityDesc: entityDesc) @@ -193,7 +193,7 @@ extension CoreDataRepository { ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .max, - context: context, + context: Transaction.current?.context ?? context, predicate: predicate, entityDesc: entityDesc, attributeDesc: attributeDesc, @@ -268,7 +268,7 @@ extension CoreDataRepository { ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .min, - context: context, + context: Transaction.current?.context ?? context, predicate: predicate, entityDesc: entityDesc, attributeDesc: attributeDesc, @@ -343,7 +343,7 @@ extension CoreDataRepository { ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .sum, - context: context, + context: Transaction.current?.context ?? context, predicate: predicate, entityDesc: entityDesc, attributeDesc: attributeDesc, @@ -428,7 +428,7 @@ extension CoreDataRepository { guard entityDesc == attributeDesc.entity else { return .failure(.propertyDoesNotMatchEntity) } - return await context.performInScratchPad { scratchPad in + return await context.performInChild { scratchPad in let request = try NSFetchRequest.request( function: function, predicate: predicate, diff --git a/Sources/CoreDataRepository/CoreDataRepository+BatchRequest.swift b/Sources/CoreDataRepository/CoreDataRepository+BatchRequest.swift index bd449c1..b3efc2d 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+BatchRequest.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+BatchRequest.swift @@ -13,7 +13,8 @@ extension CoreDataRepository { _ request: NSBatchDeleteRequest, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad { [context] scratchPad in + let context = Transaction.current?.context ?? context + return await context.performInScratchPad { [context] scratchPad in context.transactionAuthor = transactionAuthor guard let result = try scratchPad.execute(request) as? NSBatchDeleteResult else { context.transactionAuthor = nil @@ -30,7 +31,8 @@ extension CoreDataRepository { _ request: NSBatchInsertRequest, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad { [context] scratchPad in + let context = Transaction.current?.context ?? context + return await context.performInScratchPad { [context] scratchPad in context.transactionAuthor = transactionAuthor guard let result = try scratchPad.execute(request) as? NSBatchInsertResult else { context.transactionAuthor = nil @@ -47,7 +49,8 @@ extension CoreDataRepository { _ request: NSBatchUpdateRequest, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad { [context] scratchPad in + let context = Transaction.current?.context ?? context + return await context.performInScratchPad { [context] scratchPad in context.transactionAuthor = transactionAuthor guard let result = try scratchPad.execute(request) as? NSBatchUpdateResult else { context.transactionAuthor = nil diff --git a/Sources/CoreDataRepository/CoreDataRepository+Create.swift b/Sources/CoreDataRepository/CoreDataRepository+Create.swift index e6a0392..2e627c1 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Create.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Create.swift @@ -13,22 +13,26 @@ extension CoreDataRepository { _ item: Model, transactionAuthor: String? = nil ) async -> Result where Model: WritableUnmanagedModel, Model: FetchableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let object = try item.asManagedModel(in: scratchPad) let tempObjectId = object.objectID try item.updating(managed: object) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - do { - try context.save() - } catch { - let parentContextObject = context.object(with: tempObjectId) - context.delete(parentContextObject) - throw error + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + do { + try context.save() + } catch { + let parentContextObject = context.object(with: tempObjectId) + context.delete(parentContextObject) + throw error + } + context.transactionAuthor = nil } - context.transactionAuthor = nil } try scratchPad.obtainPermanentIDs(for: [object]) return try Model(managed: object) diff --git a/Sources/CoreDataRepository/CoreDataRepository+Create_Batch.swift b/Sources/CoreDataRepository/CoreDataRepository+Create_Batch.swift index 2083220..a8ac057 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Create_Batch.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Create_Batch.swift @@ -36,17 +36,21 @@ extension CoreDataRepository { _ items: [Model], transactionAuthor: String? = nil ) async -> Result<[Model], CoreDataError> where Model: FetchableUnmanagedModel, Model: WritableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let objects = try items.map { item in let object = try item.asManagedModel(in: scratchPad) return object } try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } try scratchPad.obtainPermanentIDs(for: objects) return try objects.map(Model.init(managed:)) diff --git a/Sources/CoreDataRepository/CoreDataRepository+Custom.swift b/Sources/CoreDataRepository/CoreDataRepository+Custom.swift index 4fb3d27..21c0fc2 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Custom.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Custom.swift @@ -21,6 +21,10 @@ extension CoreDataRepository { _ scratchPadContext: NSManagedObjectContext ) throws -> T ) async -> Result { - await context.performInScratchPad(schedule: schedule) { [context] scratchPad in try block(context, scratchPad) } + let context = Transaction.current?.context ?? context + return await context.performInScratchPad(schedule: schedule) { [context] scratchPad in try block( + context, + scratchPad + ) } } } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Delete.swift b/Sources/CoreDataRepository/CoreDataRepository+Delete.swift index cd2af6a..66136a6 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Delete.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Delete.swift @@ -13,17 +13,21 @@ extension CoreDataRepository { _ url: URL, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let id = try scratchPad.objectId(from: url).get() let object = try scratchPad.notDeletedObject(for: id) object.prepareForDeletion() scratchPad.delete(object) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } @@ -35,16 +39,20 @@ extension CoreDataRepository { _ managedId: NSManagedObjectID, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let object = try scratchPad.notDeletedObject(for: managedId) object.prepareForDeletion() scratchPad.delete(object) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } @@ -56,7 +64,9 @@ extension CoreDataRepository { _ item: some ReadableUnmanagedModel, transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let object = try item.readManaged(from: scratchPad) guard !object.isDeleted else { @@ -65,10 +75,12 @@ extension CoreDataRepository { object.prepareForDeletion() scratchPad.delete(object) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Delete_Batch.swift b/Sources/CoreDataRepository/CoreDataRepository+Delete_Batch.swift index cba1b47..fe93894 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Delete_Batch.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Delete_Batch.swift @@ -76,7 +76,9 @@ extension CoreDataRepository { _ managedIdUrls: [URL], transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor for url in managedIdUrls { let id = try scratchPad.objectId(from: url).get() @@ -85,10 +87,12 @@ extension CoreDataRepository { scratchPad.delete(object) } try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } @@ -100,7 +104,9 @@ extension CoreDataRepository { _ managedIds: [NSManagedObjectID], transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor for managedId in managedIds { let object = try scratchPad.notDeletedObject(for: managedId) @@ -108,10 +114,12 @@ extension CoreDataRepository { scratchPad.delete(object) } try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } @@ -123,7 +131,9 @@ extension CoreDataRepository { _ items: [some ReadableUnmanagedModel], transactionAuthor: String? = nil ) async -> Result { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor for item in items { let object = try item.readManaged(from: scratchPad) @@ -134,10 +144,12 @@ extension CoreDataRepository { scratchPad.delete(object) } try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return () } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Fetch.swift b/Sources/CoreDataRepository/CoreDataRepository+Fetch.swift index 423947e..fa8b91b 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Fetch.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Fetch.swift @@ -14,7 +14,8 @@ extension CoreDataRepository { _ request: NSFetchRequest, as _: Model.Type ) async -> Result<[Model], CoreDataError> { - await context.performInChild { fetchContext in + let context = Transaction.current?.context ?? context + return await context.performInChild { fetchContext in try fetchContext.fetch(request).map(Model.init(managed:)) } } @@ -63,7 +64,8 @@ extension CoreDataRepository { request: NSFetchRequest, operation: @escaping (_ results: [Managed]) throws -> Output ) async -> Result where Managed: NSManagedObject { - await context.performInChild { fetchContext in + let context = Transaction.current?.context ?? context + return await context.performInChild { fetchContext in try operation(fetchContext.fetch(request)) } } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Read.swift b/Sources/CoreDataRepository/CoreDataRepository+Read.swift index c89fe39..75f439d 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Read.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Read.swift @@ -12,7 +12,8 @@ extension CoreDataRepository { public func read( _ item: Model ) async -> Result { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in let managed = try item.readManaged(from: readContext) return try Model(managed: managed) } @@ -24,7 +25,8 @@ extension CoreDataRepository { _ id: Model.UnmanagedId, of _: Model.Type ) async -> Result where Model: IdentifiedUnmanagedModel { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in let managed = try Model.readManaged(id: id, from: readContext) return try Model(managed: managed) } @@ -36,7 +38,8 @@ extension CoreDataRepository { _ managedId: NSManagedObjectID, of _: Model.Type ) async -> Result where Model: FetchableUnmanagedModel { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in let object = try readContext.notDeletedObject(for: managedId) let managed: Model.ManagedModel = try object.asManagedModel() return try Model(managed: managed) @@ -49,7 +52,8 @@ extension CoreDataRepository { _ managedIdUrl: URL, of _: Model.Type ) async -> Result where Model: FetchableUnmanagedModel { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in let id = try readContext.objectId(from: managedIdUrl).get() let object = try readContext.notDeletedObject(for: id) let repoManaged: Model.ManagedModel = try object.asManagedModel() diff --git a/Sources/CoreDataRepository/CoreDataRepository+Read_Batch.swift b/Sources/CoreDataRepository/CoreDataRepository+Read_Batch.swift index 91045fd..24fb887 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Read_Batch.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Read_Batch.swift @@ -98,7 +98,8 @@ extension CoreDataRepository { _ ids: some Sequence, as _: Model.Type ) async -> Result<[Model], CoreDataError> { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in try ids.map { id in let managed = try Model.readManaged(id: id, from: readContext) guard !managed.isDeleted else { @@ -116,7 +117,8 @@ extension CoreDataRepository { public func readAtomically( _ items: some Sequence ) async -> Result<[Model], CoreDataError> { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in try items.map { item in let managed = try item.readManaged(from: readContext) guard !managed.isDeleted else { @@ -135,7 +137,8 @@ extension CoreDataRepository { _ managedIds: some Sequence, as _: Model.Type ) async -> Result<[Model], CoreDataError> { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in try managedIds.map { managedId in let _managed = try readContext.notDeletedObject(for: managedId) guard let managed = _managed as? Model.ManagedModel else { @@ -154,7 +157,8 @@ extension CoreDataRepository { _ managedIdUrls: some Sequence, as _: Model.Type ) async -> Result<[Model], CoreDataError> { - await context.performInChild(schedule: .enqueued) { readContext in + let context = Transaction.current?.context ?? context + return await context.performInChild(schedule: .enqueued) { readContext in try managedIdUrls.map { managedIdUrl in let managedId = try readContext.objectId(from: managedIdUrl).get() let _managed = try readContext.notDeletedObject(for: managedId) diff --git a/Sources/CoreDataRepository/CoreDataRepository+Update.swift b/Sources/CoreDataRepository/CoreDataRepository+Update.swift index 48c01df..8b63ea7 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Update.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Update.swift @@ -14,15 +14,19 @@ extension CoreDataRepository { with item: Model, transactionAuthor: String? = nil ) async -> Result where Model: ReadableUnmanagedModel, Model: WritableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let managed = try item.readManaged(from: scratchPad) try item.updating(managed: managed) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return try Model(managed: managed) } @@ -35,16 +39,20 @@ extension CoreDataRepository { with item: Model, transactionAuthor: String? = nil ) async -> Result where Model: FetchableUnmanagedModel, Model: WritableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let object = try scratchPad.notDeletedObject(for: managedId) let repoManaged: Model.ManagedModel = try object.asManagedModel() try item.updating(managed: repoManaged) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return try Model(managed: repoManaged) } @@ -57,17 +65,21 @@ extension CoreDataRepository { with item: Model, transactionAuthor: String? = nil ) async -> Result where Model: FetchableUnmanagedModel, Model: WritableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let id = try scratchPad.objectId(from: managedIdUrl).get() let object = try scratchPad.notDeletedObject(for: id) let repoManaged: Model.ManagedModel = try object.asManagedModel() try item.updating(managed: repoManaged) try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return try Model(managed: repoManaged) } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Update_Batch.swift b/Sources/CoreDataRepository/CoreDataRepository+Update_Batch.swift index a355bf1..7cec91e 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Update_Batch.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Update_Batch.swift @@ -37,7 +37,9 @@ extension CoreDataRepository { _ items: [Model], transactionAuthor: String? = nil ) async -> Result<[Model], CoreDataError> where Model: ReadableUnmanagedModel, Model: WritableUnmanagedModel { - await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in + let context = Transaction.current?.context ?? context + let notTransaction = Transaction.current == nil + return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in scratchPad.transactionAuthor = transactionAuthor let objects = try items.map { item in let managed = try item.readManaged(from: scratchPad) @@ -45,10 +47,12 @@ extension CoreDataRepository { return managed } try scratchPad.save() - try context.performAndWait { - context.transactionAuthor = transactionAuthor - try context.save() - context.transactionAuthor = nil + if notTransaction { + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } } return try objects.map(Model.init(managed:)) } diff --git a/Sources/CoreDataRepository/CoreDataRepository.swift b/Sources/CoreDataRepository/CoreDataRepository.swift index a8697da..2453f49 100644 --- a/Sources/CoreDataRepository/CoreDataRepository.swift +++ b/Sources/CoreDataRepository/CoreDataRepository.swift @@ -30,6 +30,83 @@ public final class CoreDataRepository: @unchecked Sendable { /// to be performed in. public let context: NSManagedObjectContext + /// Executes a block of code within a Core Data transaction so that all contained + /// operations either suceed or fail. + /// + /// - Important: When performing operations within a transaction, + /// the `transactionAuthor` is applied to the final context save operation, not to individual + /// requests. This means the transaction author will be recorded in the persistent + /// history for the entire transaction. Any `transactionAuthor` parameters for operations + /// in the transaction will be ignored. + /// + /// - Important: Batch request operations within a transaction fail to record `transactionAuthor`. + /// Hopefully, this can be fixed in the future but no workaround is available now. + /// + /// - Important: Crossing any boundary that breaks `TaskLocal` continuity will disconnect from the transaction + /// unless it is continued in a new ``withTransaction(continuing:transactionAuthor:_:)``. + /// For example, any operations performed in`Task.detached` or a `DispatchQueue` must be wrapped in + /// a new `withTransaction(continuing: transaction) { ... }`. + /// + /// - Parameters: + /// - continuing: Use an existing ``Transaction`` after crossing a `Task` boundary. + /// - transactionAuthor: An optional string to identify the author of this transaction + /// in Core Data's persistent history tracking. + /// - block: The code block to execute within the transaction context. + /// - Returns: The result of the block execution. + /// - Throws: ``CoreDataError`` if the transaction fails. + @inlinable + public func withTransaction( + continuing existingTransaction: Transaction? = nil, + transactionAuthor: String? = nil, + _ block: (Transaction) async throws(E) -> T + ) async throws(CoreDataError) -> T where E: Error { + let transaction = existingTransaction ?? Transaction(context: context.scratchPadContext()) + let scratchPad = transaction.context + return try await CoreDataError.catching { + try await Transaction.$current.withValue(transaction) { + let result = try await block(transaction) + // An existing transaction will handle all saving in its original `withTransaction` + guard existingTransaction == nil else { + return result + } + try scratchPad.performAndWait { + guard scratchPad.hasChanges else { + return + } + guard !transaction.canceled else { + scratchPad.reset() + return + } + do { + try scratchPad.save() + } catch { + scratchPad.reset() + throw error + } + } + try context.performAndWait { + guard context.hasChanges else { + return + } + guard !transaction.canceled else { + context.rollback() + return + } + context.transactionAuthor = transactionAuthor + do { + try context.save() + context.transactionAuthor = nil + } catch { + context.transactionAuthor = nil + context.rollback() + throw error + } + } + return result + } + } + } + @inlinable public init(context: NSManagedObjectContext) { self.context = context diff --git a/Sources/CoreDataRepository/Internal/NSManagedObjectContext+Scratchpad.swift b/Sources/CoreDataRepository/Internal/NSManagedObjectContext+Scratchpad.swift index cd642d5..fdfaec1 100644 --- a/Sources/CoreDataRepository/Internal/NSManagedObjectContext+Scratchpad.swift +++ b/Sources/CoreDataRepository/Internal/NSManagedObjectContext+Scratchpad.swift @@ -45,7 +45,8 @@ extension NSManagedObjectContext { return .success(output) } - private func scratchPadContext() -> NSManagedObjectContext { + @usableFromInline + func scratchPadContext() -> NSManagedObjectContext { let scratchPad = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) scratchPad.automaticallyMergesChangesFromParent = false scratchPad.parent = self diff --git a/Sources/CoreDataRepository/Transaction.swift b/Sources/CoreDataRepository/Transaction.swift new file mode 100644 index 0000000..e2fd399 --- /dev/null +++ b/Sources/CoreDataRepository/Transaction.swift @@ -0,0 +1,37 @@ +// Transaction.swift +// CoreDataRepository +// +// This source code is licensed under the MIT License (MIT) found in the +// LICENSE file in the root directory of this source tree. + +import CoreData + +/// A context where all contained operations are atomic. If any operation fails, all the operation fails. +/// If the transaction is canceled, all operations fail. +public final class Transaction: @unchecked Sendable { + @usableFromInline + let context: NSManagedObjectContext + /// Only mutated from within ``context``'s `DispatchQueue` + @usableFromInline + var canceled: Bool + + /// Removes all changes made so far. + @inlinable + public func cancel() { + context.performAndWait { + context.reset() + context.parent?.performAndWait { + context.parent?.rollback() + } + self.canceled = true + } + } + + @TaskLocal public static var current: Transaction? + + @usableFromInline + init(context: NSManagedObjectContext) { + self.context = context + canceled = false + } +} diff --git a/Tests/CoreDataRepositoryTests/AggregateTests.swift b/Tests/CoreDataRepositoryTests/AggregateTests.swift index 0fd75b9..4ff6c0a 100644 --- a/Tests/CoreDataRepositoryTests/AggregateTests.swift +++ b/Tests/CoreDataRepositoryTests/AggregateTests.swift @@ -50,14 +50,25 @@ extension CoreDataRepositoryTests { objectIds = _objectIds } - @Test - func countSuccess() async throws { - let result = await repository - .count( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - as: Int.self - ) + @Test(arguments: [false, true]) + func countSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .count( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } + } else { + await repository + .count( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } switch result { case let .success(value): expectNoDifference(value, 5, "Result value (count) should equal number of values.") @@ -66,18 +77,33 @@ extension CoreDataRepositoryTests { } } - @Test - func countSuccess_UnifiedEndpoint() async throws { - let result = try await repository.aggregate( - function: .count, - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Double.self - ) + @Test(arguments: [false, true]) + func countSuccess_UnifiedEndpoint(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.aggregate( + function: .count, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Double.self + ) + } + } else { + try await repository.aggregate( + function: .count, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Double.self + ) + } switch result { case let .success(value): expectNoDifference(value, 5, "Result value (count) should equal number of values.") @@ -86,16 +112,27 @@ extension CoreDataRepositoryTests { } } - @Test - func countSubscription() async throws { + @Test(arguments: [false, true]) + func countSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = repository - .countSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - as: Int.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .countSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } + } else { + repository + .countSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } for await _count in stream { let count = try _count.get() resultCount += 1 @@ -118,16 +155,27 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func countThrowingSubscription() async throws { + @Test(arguments: [false, true]) + func countThrowingSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = repository - .countThrowingSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - as: Int.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .countThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } + } else { + repository + .countThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } for try await count in stream { resultCount += 1 switch resultCount { @@ -149,17 +197,31 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func sumSuccess() async throws { - let result = try await repository.sum( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func sumSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.sum( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.sum( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference(value, 150, "Result value (sum) should equal number of values.") @@ -168,18 +230,33 @@ extension CoreDataRepositoryTests { } } - @Test - func sumSuccess_UnifiedEndpoint() async throws { - let result = try await repository.aggregate( - function: .sum, - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func sumSuccess_UnifiedEndpoint(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.aggregate( + function: .sum, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.aggregate( + function: .sum, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference(value, 150, "Result value (sum) should equal number of values.") @@ -188,19 +265,33 @@ extension CoreDataRepositoryTests { } } - @Test - func sumSubscription() async throws { + @Test(arguments: [false, true]) + func sumSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.sumSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.sumSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.sumSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for await _sum in stream { let sum = try _sum.get() resultCount += 1 @@ -227,19 +318,33 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func sumThrowingSubscription() async throws { + @Test(arguments: [false, true]) + func sumThrowingSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.sumThrowingSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.sumThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.sumThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for try await sum in stream { resultCount += 1 switch resultCount { @@ -265,17 +370,31 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func averageSuccess() async throws { - let result = try await repository.average( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func averageSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.average( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.average( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -288,18 +407,33 @@ extension CoreDataRepositoryTests { } } - @Test - func averageSuccess_UnifiedEndpoint() async throws { - let result = try await repository.aggregate( - function: .average, - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func averageSuccess_UnifiedEndpoint(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.aggregate( + function: .average, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.aggregate( + function: .average, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -312,19 +446,33 @@ extension CoreDataRepositoryTests { } } - @Test - func averageSubscription() async throws { + @Test(arguments: [false, true]) + func averageSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.averageSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.averageSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.averageSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for await _average in stream { let average = try _average.get() resultCount += 1 @@ -351,19 +499,33 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func averageThrowingSubscription() async throws { + @Test(arguments: [false, true]) + func averageThrowingSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.averageThrowingSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.averageThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.averageThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for try await average in stream { resultCount += 1 switch resultCount { @@ -389,17 +551,31 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func minSuccess() async throws { - let result = try await repository.min( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func minSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.min( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.min( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -412,18 +588,33 @@ extension CoreDataRepositoryTests { } } - @Test - func minSuccess_UnifiedEndpoint() async throws { - let result = try await repository.aggregate( - function: .min, - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func minSuccess_UnifiedEndpoint(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.aggregate( + function: .min, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.aggregate( + function: .min, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -436,19 +627,33 @@ extension CoreDataRepositoryTests { } } - @Test - func minSubscription() async throws { + @Test(arguments: [false, true]) + func minSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.minSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.minSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.minSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for await _min in stream { let min = try _min.get() resultCount += 1 @@ -475,19 +680,33 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func minThrowingSubscription() async throws { + @Test(arguments: [false, true]) + func minThrowingSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.minThrowingSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.minThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.minThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for try await min in stream { resultCount += 1 switch resultCount { @@ -513,17 +732,31 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func maxSuccess() async throws { - let result = try await repository.max( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func maxSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.max( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.max( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -536,18 +769,33 @@ extension CoreDataRepositoryTests { } } - @Test - func maxSuccess_UnifiedEndpoint() async throws { - let result = try await repository.aggregate( - function: .max, - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + @Test(arguments: [false, true]) + func maxSuccess_UnifiedEndpoint(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.aggregate( + function: .max, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.aggregate( + function: .max, + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } switch result { case let .success(value): expectNoDifference( @@ -560,19 +808,33 @@ extension CoreDataRepositoryTests { } } - @Test - func maxSubscription() async throws { + @Test(arguments: [false, true]) + func maxSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.maxSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.maxSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.maxSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for await _max in stream { let max = try _max.get() resultCount += 1 @@ -599,19 +861,33 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func maxThrowingSubscription() async throws { + @Test(arguments: [false, true]) + func maxThrowingSubscription(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = try repository.maxThrowingSubscription( - predicate: NSPredicate(value: true), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository.maxThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try repository.maxThrowingSubscription( + predicate: NSPredicate(value: true), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } for try await max in stream { resultCount += 1 switch resultCount { @@ -637,10 +913,64 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func countWithPredicate() async throws { - let result = await repository - .count( + @Test(arguments: [false, true]) + func countWithPredicate(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .count( + predicate: NSComparisonPredicate( + leftExpression: NSExpression(forKeyPath: \ManagedModel_UuidId.string), + rightExpression: NSExpression(forConstantValue: "10"), + modifier: .direct, + type: .notEqualTo + ), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } + } else { + await repository + .count( + predicate: NSComparisonPredicate( + leftExpression: NSExpression(forKeyPath: \ManagedModel_UuidId.string), + rightExpression: NSExpression(forConstantValue: "10"), + modifier: .direct, + type: .notEqualTo + ), + entityDesc: ManagedModel_UuidId.entity(), + as: Int.self + ) + } + switch result { + case let .success(count): + expectNoDifference(count, 4, "Result value (count) should equal number of values not titled 'A'.") + case .failure: + Issue.record("Not expecting failure") + } + } + + @Test(arguments: [false, true]) + func sumWithPredicate(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository.sum( + predicate: NSComparisonPredicate( + leftExpression: NSExpression(forKeyPath: \ManagedModel_UuidId.string), + rightExpression: NSExpression(forConstantValue: "10"), + modifier: .direct, + type: .notEqualTo + ), + entityDesc: ManagedModel_UuidId.entity(), + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self + ) + } + } else { + try await repository.sum( predicate: NSComparisonPredicate( leftExpression: NSExpression(forKeyPath: \ManagedModel_UuidId.string), rightExpression: NSExpression(forConstantValue: "10"), @@ -648,32 +978,13 @@ extension CoreDataRepositoryTests { type: .notEqualTo ), entityDesc: ManagedModel_UuidId.entity(), - as: Int.self + attributeDesc: #require( + ManagedModel_UuidId.entity().attributesByName.values + .first(where: { $0.name == "decimal" }) + ), + as: Decimal.self ) - switch result { - case let .success(count): - expectNoDifference(count, 4, "Result value (count) should equal number of values not titled 'A'.") - case .failure: - Issue.record("Not expecting failure") } - } - - @Test - func sumWithPredicate() async throws { - let result = try await repository.sum( - predicate: NSComparisonPredicate( - leftExpression: NSExpression(forKeyPath: \ManagedModel_UuidId.string), - rightExpression: NSExpression(forConstantValue: "10"), - modifier: .direct, - type: .notEqualTo - ), - entityDesc: ManagedModel_UuidId.entity(), - attributeDesc: #require( - ManagedModel_UuidId.entity().attributesByName.values - .first(where: { $0.name == "decimal" }) - ), - as: Decimal.self - ) switch result { case let .success(sum): expectNoDifference( diff --git a/Tests/CoreDataRepositoryTests/BatchRequestTests.swift b/Tests/CoreDataRepositoryTests/BatchRequestTests.swift index 2485dc7..cba3de5 100644 --- a/Tests/CoreDataRepositoryTests/BatchRequestTests.swift +++ b/Tests/CoreDataRepositoryTests/BatchRequestTests.swift @@ -68,8 +68,8 @@ extension CoreDataRepositoryTests { ) } - @Test - func insertSuccess() async throws { + @Test(arguments: [false, true]) + func insertSuccess(inTransaction: Bool) async throws { let fetchRequest = ManagedIdUrlModel_UuidId.managedFetchRequest() try await repositoryContext.perform { let count = try repositoryContext.count(for: fetchRequest) @@ -83,8 +83,15 @@ extension CoreDataRepositoryTests { entityName: #require(ManagedModel_UuidId.entity().name), objects: values ) - let result: Result = await repository - .insert(request, transactionAuthor: transactionAuthor) + let result = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .insert(request) + } + } else { + await repository + .insert(request, transactionAuthor: transactionAuthor) + } switch result { case .success: @@ -102,11 +109,18 @@ extension CoreDataRepositoryTests { ) } - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + // Transaction author refuses to be applied when going through a transaction. Need to investigate further. + if inTransaction { + withKnownIssue { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + } else { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } } - @Test - func insertFailure() async throws { + @Test(arguments: [false, true]) + func insertFailure(inTransaction: Bool) async throws { let fetchRequest = ManagedIdUrlModel_UuidId.managedFetchRequest() try await repositoryContext.perform { let count = try repositoryContext.count(for: fetchRequest) @@ -117,7 +131,13 @@ extension CoreDataRepositoryTests { entityName: #require(ManagedModel_UuidId.entity().name), objects: failureInsertMovies ) - let result: Result = await repository.insert(request) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository.insert(request) + } + } else { + await repository.insert(request) + } switch result { case .success: @@ -132,8 +152,8 @@ extension CoreDataRepositoryTests { } } - @Test - func updateSuccess() async throws { + @Test(arguments: [false, true]) + func updateSuccess(inTransaction: Bool) async throws { let fetchRequest = ManagedIdUrlModel_UuidId.managedFetchRequest() try await repositoryContext.perform { let count = try repositoryContext.count(for: fetchRequest) @@ -152,8 +172,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let _: Result = await repository - .update(request, transactionAuthor: transactionAuthor) + _ = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .update(request) + } + } else { + await repository + .update(request, transactionAuthor: transactionAuthor) + } try await repositoryContext.perform { let data = try repositoryContext.fetch(fetchRequest) @@ -163,11 +190,18 @@ extension CoreDataRepositoryTests { "Updated titles should match request" ) } - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + // Transaction author refuses to be applied when going through a transaction. Need to investigate further. + if inTransaction { + withKnownIssue { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + } else { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } } - @Test - func deleteSuccess() async throws { + @Test(arguments: [false, true]) + func deleteSuccess(inTransaction: Bool) async throws { let fetchRequest = ManagedIdUrlModel_UuidId.managedFetchRequest() try await repositoryContext.perform { let count = try repositoryContext.count(for: fetchRequest) @@ -187,14 +221,28 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let _: Result = await repository - .delete(request, transactionAuthor: transactionAuthor) + _ = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(request, transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(request, transactionAuthor: transactionAuthor) + } try await repositoryContext.perform { let data = try repositoryContext.fetch(fetchRequest) expectNoDifference(data.map(\.string).sorted(), [], "There should be no remaining values.") } - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + // Transaction author refuses to be applied when going through a transaction. Need to investigate further. + if inTransaction { + withKnownIssue { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + } else { + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } } init( diff --git a/Tests/CoreDataRepositoryTests/CreateTests.swift b/Tests/CoreDataRepositoryTests/CreateTests.swift index eb60dff..26561b2 100644 --- a/Tests/CoreDataRepositoryTests/CreateTests.swift +++ b/Tests/CoreDataRepositoryTests/CreateTests.swift @@ -17,22 +17,30 @@ extension CoreDataRepositoryTests { let repositoryContext: NSManagedObjectContext let repository: CoreDataRepository - @Test - func create_Fetchable_Success() async throws { + @Test(arguments: [false, true]) + func create_Fetchable_Success(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let historyTimeStamp = Date() let transactionAuthor: String = #function let _value = modelType.seeded(1) - let value = try await repository - .create(_value, transactionAuthor: transactionAuthor).get() + let value = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .create(_value).get() + } + } else { + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } + expectNoDifference(value, _value) try await verify(value) try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_Fetchable_Failure() async throws { + @Test(arguments: [false, true]) + func create_Fetchable_Failure(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -45,35 +53,64 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let result = await repository - .create(_value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .create(_value) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .create(_value) - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } } } - @Test - func create_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func create_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let historyTimeStamp = Date() let transactionAuthor: String = #function let _value = modelType.seeded(1) - let value = try await repository - .create(_value, transactionAuthor: transactionAuthor).get() + let value = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } + expectNoDifference(value, _value) try await verify(value) try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func create_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -86,35 +123,63 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let result = await repository - .create(_value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .create(_value) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .create(_value) - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } } } - @Test - func create_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let historyTimeStamp = Date() let transactionAuthor: String = #function let _value = modelType.seeded(1) - let value = try await repository - .create(_value, transactionAuthor: transactionAuthor).get() + let value = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } expectNoDifference(value.removingManagedId(), _value) try await verify(value) try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -127,6 +192,26 @@ extension CoreDataRepositoryTests { try await verify(existingValue) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .create(_value) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } let result = await repository .create(_value) @@ -140,22 +225,29 @@ extension CoreDataRepositoryTests { } } - @Test - func create_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let historyTimeStamp = Date() let transactionAuthor: String = #function let _value = modelType.seeded(1) - let value = try await repository - .create(_value, transactionAuthor: transactionAuthor).get() + let value = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .create(_value, transactionAuthor: transactionAuthor).get() + } expectNoDifference(value.removingManagedIdUrl(), _value) try await verify(value) try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -168,16 +260,37 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let result = await repository - .create(_value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .create(_value) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .create(_value) - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } } } diff --git a/Tests/CoreDataRepositoryTests/Create_BatchTests.swift b/Tests/CoreDataRepositoryTests/Create_BatchTests.swift index 210a7bc..83947ed 100644 --- a/Tests/CoreDataRepositoryTests/Create_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Create_BatchTests.swift @@ -17,8 +17,8 @@ extension CoreDataRepositoryTests { let repositoryContext: NSManagedObjectContext let repository: CoreDataRepository - @Test - func create_Fetchable_Success() async throws { + @Test(arguments: [false, true]) + func create_Fetchable_Success(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -38,8 +38,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } else { + await repository + .create(_values, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -60,8 +67,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_Fetchable_Failure() async throws { + @Test(arguments: [false, true]) + func create_Fetchable_Failure(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let _values = [ @@ -85,30 +92,51 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) - - expectNoDifference(successful.count, _values.count - 1) - expectNoDifference(failed.count, 1) - - for value in successful { - try await verify(value) - } - - try await repositoryContext.perform { - let data = try repositoryContext.fetch(fetchRequest) - expectNoDifference( - data.map(\.string).sorted(), - ["1", "2", "3", "4", "5"], - "Inserted titles should match expectation" - ) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let (successful, failed) = await repository + .create(_values, transactionAuthor: transactionAuthor) + + expectNoDifference(successful.count, _values.count - 1) + expectNoDifference(failed.count, 1) + + for value in successful { + try await verify(value) + } + + try await repositoryContext.perform { + let data = try repositoryContext.fetch(fetchRequest) + expectNoDifference( + data.map(\.string).sorted(), + ["1", "2", "3", "4", "5"], + "Inserted titles should match expectation" + ) + } + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func create_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -128,8 +156,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } else { + await repository + .create(_values, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -150,8 +185,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func create_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ @@ -175,30 +210,51 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) - - expectNoDifference(successful.count, _values.count - 1) - expectNoDifference(failed.count, 1) - - for value in successful { - try await verify(value) - } - - try await repositoryContext.perform { - let data = try repositoryContext.fetch(fetchRequest) - expectNoDifference( - data.map(\.string).sorted(), - ["1", "2", "3", "4", "5"], - "Inserted titles should match expectation" - ) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let (successful, failed) = await repository + .create(_values, transactionAuthor: transactionAuthor) + + expectNoDifference(successful.count, _values.count - 1) + expectNoDifference(failed.count, 1) + + for value in successful { + try await verify(value) + } + + try await repositoryContext.perform { + let data = try repositoryContext.fetch(fetchRequest) + expectNoDifference( + data.map(\.string).sorted(), + ["1", "2", "3", "4", "5"], + "Inserted titles should match expectation" + ) + } + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -218,8 +274,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } else { + await repository + .create(_values, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -242,8 +305,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -267,30 +330,51 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) - - expectNoDifference(successful.count, _values.count - 1) - expectNoDifference(failed.count, 1) - - for value in successful { - try await verify(value) - } - - try await repositoryContext.perform { - let data = try repositoryContext.fetch(fetchRequest) - expectNoDifference( - data.map(\.string).sorted(), - ["1", "2", "3", "4", "5"], - "Inserted titles should match expectation" - ) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let (successful, failed) = await repository + .create(_values, transactionAuthor: transactionAuthor) + + expectNoDifference(successful.count, _values.count - 1) + expectNoDifference(failed.count, 1) + + for value in successful { + try await verify(value) + } + + try await repositoryContext.perform { + let data = try repositoryContext.fetch(fetchRequest) + expectNoDifference( + data.map(\.string).sorted(), + ["1", "2", "3", "4", "5"], + "Inserted titles should match expectation" + ) + } + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -310,8 +394,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } else { + await repository + .create(_values, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -334,8 +425,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func create_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func create_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -359,30 +450,51 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .create(_values, transactionAuthor: transactionAuthor) - - expectNoDifference(successful.count, _values.count - 1) - expectNoDifference(failed.count, 1) - - for value in successful { - try await verify(value) - } - - try await repositoryContext.perform { - let data = try repositoryContext.fetch(fetchRequest) - expectNoDifference( - data.map(\.string).sorted(), - ["1", "2", "3", "4", "5"], - "Inserted titles should match expectation" - ) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .create(_values, transactionAuthor: transactionAuthor) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let (successful, failed) = await repository + .create(_values, transactionAuthor: transactionAuthor) + + expectNoDifference(successful.count, _values.count - 1) + expectNoDifference(failed.count, 1) + + for value in successful { + try await verify(value) + } + + try await repositoryContext.perform { + let data = try repositoryContext.fetch(fetchRequest) + expectNoDifference( + data.map(\.string).sorted(), + ["1", "2", "3", "4", "5"], + "Inserted titles should match expectation" + ) + } + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - - try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func createAtomically_Fetchable_Success() async throws { + @Test(arguments: [false, true]) + func createAtomically_Fetchable_Success(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -402,8 +514,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let createdValues = try await repository - .createAtomically(_values, transactionAuthor: transactionAuthor).get() + let createdValues = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } expectNoDifference(createdValues.count, _values.count) @@ -425,8 +544,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func createAtomically_Fetchable_Failure() async throws { + @Test(arguments: [false, true]) + func createAtomically_Fetchable_Failure(inTransaction: Bool) async throws { let modelType = FetchableModel_UuidId.self let _values = [ @@ -449,25 +568,46 @@ extension CoreDataRepositoryTests { } try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) - let result = await repository - .createAtomically(_values) - - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") - } - - for value in _values[1 ... 4] { - try await verifyDoesNotExist(value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .createAtomically(_values) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .createAtomically(_values) + + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } + + for value in _values[1 ... 4] { + try await verifyDoesNotExist(value) + } } } - @Test - func createAtomically_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func createAtomically_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -487,8 +627,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let createdValues = try await repository - .createAtomically(_values, transactionAuthor: transactionAuthor).get() + let createdValues = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } expectNoDifference(createdValues.count, _values.count) @@ -510,8 +657,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func createAtomically_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func createAtomically_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ @@ -534,25 +681,46 @@ extension CoreDataRepositoryTests { } try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) - let result = await repository - .createAtomically(_values) - - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") - } - - for value in _values[1 ... 4] { - try await verifyDoesNotExist(value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .createAtomically(_values) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .createAtomically(_values) + + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } + + for value in _values[1 ... 4] { + try await verifyDoesNotExist(value) + } } } - @Test - func createAtomically_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func createAtomically_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -572,8 +740,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let createdValues = try await repository - .createAtomically(_values, transactionAuthor: transactionAuthor).get() + let createdValues = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } expectNoDifference(createdValues.count, _values.count) @@ -595,8 +770,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func createAtomically_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func createAtomically_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -619,25 +794,46 @@ extension CoreDataRepositoryTests { } try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) - let result = await repository - .createAtomically(_values) - - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") - } - - for value in _values[1 ... 4] { - try await verifyDoesNotExist(value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .createAtomically(_values) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .createAtomically(_values) + + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } + + for value in _values[1 ... 4] { + try await verifyDoesNotExist(value) + } } } - @Test - func createAtomically_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func createAtomically_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let fetchRequest = modelType.managedFetchRequest() @@ -657,8 +853,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let createdValues = try await repository - .createAtomically(_values, transactionAuthor: transactionAuthor).get() + let createdValues = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .createAtomically(_values, transactionAuthor: transactionAuthor).get() + } expectNoDifference(createdValues.count, _values.count) @@ -680,8 +883,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func createAtomically_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func createAtomically_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -704,20 +907,41 @@ extension CoreDataRepositoryTests { } try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) - let result = await repository - .createAtomically(_values) - - switch result { - case .success: - Issue.record("Not expecting success") - case let .failure(.cocoa(cocoaError)): - expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) - case let .failure(error): - Issue.record("Unexpected error: \(error)") - } - - for value in _values[1 ... 4] { - try await verifyDoesNotExist(value) + if inTransaction { + try await withKnownIssue { + _ = try await repository.withTransaction { _ in + await repository + .createAtomically(_values) + } + } matching: { issue in + guard let error = issue.error as? CoreDataError else { + return false + } + switch error { + case let .cocoa(cocoaError): + let nsError = cocoaError as NSError + return nsError.code == 133_021 + && nsError.domain == "NSCocoaErrorDomain" + default: + return false + } + } + } else { + let result = await repository + .createAtomically(_values) + + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + expectNoDifference(cocoaError.code, .managedObjectConstraintMerge) + case let .failure(error): + Issue.record("Unexpected error: \(error)") + } + + for value in _values[1 ... 4] { + try await verifyDoesNotExist(value) + } } } diff --git a/Tests/CoreDataRepositoryTests/CustomTests.swift b/Tests/CoreDataRepositoryTests/CustomTests.swift new file mode 100644 index 0000000..8bee281 --- /dev/null +++ b/Tests/CoreDataRepositoryTests/CustomTests.swift @@ -0,0 +1,136 @@ +// CustomTests.swift +// CoreDataRepository +// +// This source code is licensed under the MIT License (MIT) found in the +// LICENSE file in the root directory of this source tree. + +import CoreData +import CoreDataRepository +import CustomDump +import Internal +import Testing + +extension CoreDataRepositoryTests { + @Suite + struct CustomTests: CoreDataTestSuite { + let container: NSPersistentContainer + let repositoryContext: NSManagedObjectContext + let repository: CoreDataRepository + + @Test(arguments: [false, true]) + func custom_Success(inTransaction: Bool) async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let _value = modelType.seeded(1) + + let result: Result = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository.custom { _, scratchPad in + let object = modelType.ManagedModel(context: scratchPad) + try _value.updating(managed: object) + try scratchPad.save() + return try modelType.init(managed: object) + } + } + } else { + await repository.custom { context, scratchPad in + let object = modelType.ManagedModel(context: scratchPad) + try _value.updating(managed: object) + try scratchPad.save() + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } + return try modelType.init(managed: object) + } + } + + switch result { + case let .success(value): + try await verify(value) + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + case let .failure(error): + Issue.record(error) + } + } + + @Test(arguments: [false, true]) + func custom_Failure(inTransaction: Bool) async throws { + let modelType = FetchableModel_UuidId.self + let transactionAuthor: String = #function + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + + try repositoryContext.performAndWait { + let object = modelType.ManagedModel(context: repositoryContext) + try _value1.updating(managed: object) + try repositoryContext.save() + } + + let result: Result<(FetchableModel_UuidId, FetchableModel_UuidId), CoreDataError> = if inTransaction { + await { + do { + let result = try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository.custom { context, scratchPad in + let object1 = modelType.ManagedModel(context: scratchPad) + try _value1.updating(managed: object1) + let object2 = modelType.ManagedModel(context: scratchPad) + try _value2.updating(managed: object2) + try scratchPad.save() + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } + return try (modelType.init(managed: object1), modelType.init(managed: object2)) + } + } + return result + } catch { + return .failure(error as! CoreDataError) + } + }() + } else { + await repository.custom { context, scratchPad in + let object1 = modelType.ManagedModel(context: scratchPad) + try _value1.updating(managed: object1) + let object2 = modelType.ManagedModel(context: scratchPad) + try _value2.updating(managed: object2) + try scratchPad.save() + try context.performAndWait { + context.transactionAuthor = transactionAuthor + try context.save() + context.transactionAuthor = nil + } + return try (modelType.init(managed: object1), modelType.init(managed: object2)) + } + } + + switch result { + case .success: + Issue.record("Not expecting success") + case let .failure(.cocoa(cocoaError)): + let nsError = cocoaError as NSError + expectNoDifference(nsError.domain, "NSCocoaErrorDomain") + expectNoDifference(nsError.code, 133_021) + case let .failure(error): + Issue.record(error, "Expecting different error") + } + + try await verify(_value1) + try await verifyDoesNotExist(_value2) + } + + init( + container: NSPersistentContainer, + repositoryContext: NSManagedObjectContext, + repository: CoreDataRepository + ) { + self.container = container + self.repositoryContext = repositoryContext + self.repository = repository + } + } +} diff --git a/Tests/CoreDataRepositoryTests/Delete_BatchTests.swift b/Tests/CoreDataRepositoryTests/Delete_BatchTests.swift index aa96f1d..10126e5 100644 --- a/Tests/CoreDataRepositoryTests/Delete_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Delete_BatchTests.swift @@ -19,8 +19,8 @@ extension CoreDataRepositoryTests { // MARK: Non Atomic - @Test - func delete_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func delete_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -44,16 +44,23 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .delete(existingValues, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func delete_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func delete_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -62,15 +69,23 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let (successful, failed) = await repository - .delete(_values) + + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .delete(_values) + } + } else { + await repository + .delete(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func delete_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -94,8 +109,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .delete(existingValues, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -105,8 +127,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func delete_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -130,8 +152,15 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = await repository - .delete(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .delete(_values) + } + } else { + await repository + .delete(_values) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) @@ -140,8 +169,8 @@ extension CoreDataRepositoryTests { } } - @Test - func delete_ManagedIdReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -152,15 +181,22 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let (successful, failed) = await repository - .delete(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .delete(_values) + } + } else { + await repository + .delete(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func delete_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func delete_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -184,8 +220,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .delete(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -195,8 +238,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func delete_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -225,8 +268,15 @@ extension CoreDataRepositoryTests { } try await verifyDoesNotExist(_values[0]) - let (successful, failed) = try await repository - .delete(_values.map { try #require($0.managedId) }) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .delete(_values.map { try #require($0.managedId) }) + } + } else { + try await repository + .delete(_values.map { try #require($0.managedId) }) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) @@ -235,8 +285,8 @@ extension CoreDataRepositoryTests { } } - @Test - func delete_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -260,8 +310,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .delete(existingValues, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(existingValues, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -271,8 +328,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func delete_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -296,8 +353,15 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = await repository - .delete(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .delete(_values) + } + } else { + await repository + .delete(_values) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) @@ -306,8 +370,8 @@ extension CoreDataRepositoryTests { } } - @Test - func delete_ManagedIdUrlReferencable_NoManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdUrlReferencable_NoManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -318,15 +382,22 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let (successful, failed) = await repository - .delete(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .delete(_values) + } + } else { + await repository + .delete(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func delete_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -350,8 +421,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - let (successful, failed) = await repository - .delete(existingValues.compactMap(\.managedIdUrl), transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .delete(existingValues.compactMap(\.managedIdUrl), transactionAuthor: transactionAuthor) + } + } else { + await repository + .delete(existingValues.compactMap(\.managedIdUrl), transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -361,8 +439,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func delete_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func delete_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -386,8 +464,15 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = try await repository - .delete(_values.map { try #require($0.managedIdUrl) }) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .delete(_values.map { try #require($0.managedIdUrl) }) + } + } else { + try await repository + .delete(_values.map { try #require($0.managedIdUrl) }) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) @@ -398,8 +483,8 @@ extension CoreDataRepositoryTests { // MARK: Atomic - @Test - func deleteAtomically_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -423,8 +508,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - try await repository - .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } for value in existingValues { try await verifyDoesNotExist(value) @@ -432,8 +524,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func deleteAtomically_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -442,8 +534,16 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let result = await repository - .deleteAtomically(_values) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .deleteAtomically(_values) + } + } else { + await repository + .deleteAtomically(_values) + } switch result { case .success: @@ -455,8 +555,8 @@ extension CoreDataRepositoryTests { } } - @Test - func deleteAtomically_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -480,8 +580,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - try await repository - .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } for value in existingValues { try await verifyDoesNotExist(value) @@ -489,8 +596,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func deleteAtomically_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -514,8 +621,15 @@ extension CoreDataRepositoryTests { return values } - let result = await repository - .deleteAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .deleteAtomically(_values) + } + } else { + await repository + .deleteAtomically(_values) + } switch result { case .success: @@ -532,8 +646,8 @@ extension CoreDataRepositoryTests { try await verifyDoesNotExist(_values[0]) } - @Test - func deleteAtomically_ManagedIdReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -544,8 +658,15 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let result = await repository - .deleteAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .deleteAtomically(_values) + } + } else { + await repository + .deleteAtomically(_values) + } switch result { case .success: @@ -557,8 +678,8 @@ extension CoreDataRepositoryTests { } } - @Test - func deleteAtomically_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -582,8 +703,17 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - try await repository - .deleteAtomically(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor).get() + if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .deleteAtomically(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor) + .get() + } + } else { + try await repository + .deleteAtomically(existingValues.compactMap(\.managedId), transactionAuthor: transactionAuthor) + .get() + } for value in existingValues { try await verifyDoesNotExist(value) @@ -591,8 +721,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func deleteAtomically_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -621,8 +751,15 @@ extension CoreDataRepositoryTests { } try await verifyDoesNotExist(_values[0]) - let result = try await repository - .deleteAtomically(_values.map { try #require($0.managedId) }) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .deleteAtomically(_values.map { try #require($0.managedId) }) + } + } else { + try await repository + .deleteAtomically(_values.map { try #require($0.managedId) }) + } switch result { case .success: @@ -639,8 +776,8 @@ extension CoreDataRepositoryTests { try await verifyDoesNotExist(_values[0]) } - @Test - func deleteAtomically_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -664,8 +801,15 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - try await repository - .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } + } else { + try await repository + .deleteAtomically(existingValues, transactionAuthor: transactionAuthor).get() + } for value in existingValues { try await verifyDoesNotExist(value) @@ -673,8 +817,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func deleteAtomically_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -698,8 +842,15 @@ extension CoreDataRepositoryTests { return values } - let result = await repository - .deleteAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .deleteAtomically(_values) + } + } else { + await repository + .deleteAtomically(_values) + } switch result { case .success: @@ -716,8 +867,8 @@ extension CoreDataRepositoryTests { try await verifyDoesNotExist(_values[0]) } - @Test - func deleteAtomically_ManagedIdUrlReferencable_NoManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdUrlReferencable_NoManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -728,8 +879,15 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let result = await repository - .deleteAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .deleteAtomically(_values) + } + } else { + await repository + .deleteAtomically(_values) + } switch result { case .success: @@ -741,8 +899,8 @@ extension CoreDataRepositoryTests { } } - @Test - func deleteAtomically_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -766,8 +924,19 @@ extension CoreDataRepositoryTests { let historyTimeStamp = Date() let transactionAuthor: String = #function - try await repository - .deleteAtomically(existingValues.compactMap(\.managedIdUrl), transactionAuthor: transactionAuthor).get() + if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .deleteAtomically( + existingValues.compactMap(\.managedIdUrl), + transactionAuthor: transactionAuthor + ).get() + } + } else { + try await repository + .deleteAtomically(existingValues.compactMap(\.managedIdUrl), transactionAuthor: transactionAuthor) + .get() + } for value in existingValues { try await verifyDoesNotExist(value) @@ -775,8 +944,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func deleteAtomically_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func deleteAtomically_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -800,8 +969,15 @@ extension CoreDataRepositoryTests { return values } - let result = try await repository - .deleteAtomically(_values.map { try #require($0.managedIdUrl) }) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .deleteAtomically(_values.map { try #require($0.managedIdUrl) }) + } + } else { + try await repository + .deleteAtomically(_values.map { try #require($0.managedIdUrl) }) + } switch result { case .success: diff --git a/Tests/CoreDataRepositoryTests/FetchTests.swift b/Tests/CoreDataRepositoryTests/FetchTests.swift index 4c17db1..4ea8e51 100644 --- a/Tests/CoreDataRepositoryTests/FetchTests.swift +++ b/Tests/CoreDataRepositoryTests/FetchTests.swift @@ -50,9 +50,17 @@ extension CoreDataRepositoryTests { objectIds = _objectIds } - @Test - func fetchSuccess() async throws { - switch await repository.fetch(fetchRequest, as: FetchableModel_UuidId.self) { + @Test(arguments: [false, true]) + func fetchSuccess(inTransaction: Bool) async throws { + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository.fetch(fetchRequest, as: FetchableModel_UuidId.self) + } + } else { + await repository.fetch(fetchRequest, as: FetchableModel_UuidId.self) + } + + switch result { case let .success(values): expectNoDifference(values.count, 5, "Result items count should match expectation") expectNoDifference(values, expectedValues, "Result items should match expectations") @@ -61,12 +69,19 @@ extension CoreDataRepositoryTests { } } - @Test - func fetchSubscriptionSuccess() async throws { + @Test(arguments: [false, true]) + func fetchSubscriptionSuccess(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = repository - .fetchSubscription(fetchRequest, of: FetchableModel_UuidId.self) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .fetchSubscription(fetchRequest, of: FetchableModel_UuidId.self) + } + } else { + repository + .fetchSubscription(fetchRequest, of: FetchableModel_UuidId.self) + } for await _items in stream { let items = try _items.get() resultCount += 1 @@ -95,14 +110,23 @@ extension CoreDataRepositoryTests { expectNoDifference(finalCount, 2) } - @Test - func fetchThrowingSubscriptionSuccess() async throws { + @Test(arguments: [false, true]) + func fetchThrowingSubscriptionSuccess(inTransaction: Bool) async throws { let task = Task { var resultCount = 0 - let stream = repository.fetchThrowingSubscription( - fetchRequest, - of: FetchableModel_UuidId.self - ) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository.fetchThrowingSubscription( + fetchRequest, + of: FetchableModel_UuidId.self + ) + } + } else { + repository.fetchThrowingSubscription( + fetchRequest, + of: FetchableModel_UuidId.self + ) + } for try await items in stream { resultCount += 1 switch resultCount { diff --git a/Tests/CoreDataRepositoryTests/ReadTests.swift b/Tests/CoreDataRepositoryTests/ReadTests.swift index b30be60..d8c8785 100644 --- a/Tests/CoreDataRepositoryTests/ReadTests.swift +++ b/Tests/CoreDataRepositoryTests/ReadTests.swift @@ -17,8 +17,8 @@ extension CoreDataRepositoryTests { let repositoryContext: NSManagedObjectContext let repository: CoreDataRepository - @Test - func read_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -31,18 +31,33 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let value = try await repository - .read(existingValue).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(existingValue).get() + } + } else { + try await repository + .read(existingValue).get() + } expectNoDifference(value, existingValue) } - @Test - func read_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .read(_value) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_value) + } + } else { + await repository + .read(_value) + } switch result { case .success: @@ -54,8 +69,8 @@ extension CoreDataRepositoryTests { } } - @Test - func read_Identifiable_ById_Success() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_ById_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -68,16 +83,33 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - _ = try await repository - .read(existingValue.id, of: modelType).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(existingValue.id, of: modelType).get() + } + } else { + try await repository + .read(existingValue.id, of: modelType).get() + } + + expectNoDifference(value, existingValue) } - @Test - func read_Identifiable_ById_Failure() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_ById_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .read(_value.id, of: modelType) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_value.id, of: modelType) + } + } else { + await repository + .read(_value.id, of: modelType) + } switch result { case .success: @@ -89,8 +121,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readSubscription_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -103,8 +135,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readSubscription(_value) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readSubscription(_value) + } + } else { + repository + .readSubscription(_value) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -124,8 +163,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -138,8 +177,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readThrowingSubscription(_value) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readThrowingSubscription(_value) + } + } else { + repository + .readThrowingSubscription(_value) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) @@ -159,8 +205,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readSubscription_Identifiable_ById_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_Identifiable_ById_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -173,8 +219,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readSubscription(_value.id, of: modelType) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readSubscription(_value.id, of: modelType) + } + } else { + repository + .readSubscription(_value.id, of: modelType) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -194,8 +247,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_Identifiable_ById_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_Identifiable_ById_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -208,8 +261,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readThrowingSubscription(_value.id, of: modelType) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readThrowingSubscription(_value.id, of: modelType) + } + } else { + repository + .readThrowingSubscription(_value.id, of: modelType) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) @@ -229,8 +289,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func read_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -243,12 +303,21 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - _ = try await repository - .read(#require(existingValue.managedId), of: modelType).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(#require(existingValue.managedId), of: modelType).get() + } + } else { + try await repository + .read(#require(existingValue.managedId), of: modelType).get() + } + + expectNoDifference(value, existingValue) } - @Test - func read_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -265,8 +334,16 @@ extension CoreDataRepositoryTests { return value } - let result = try await repository - .read(#require(existingValue.managedId), of: modelType) + + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(#require(existingValue.managedId), of: modelType) + } + } else { + try await repository + .read(#require(existingValue.managedId), of: modelType) + } switch result { case .success: @@ -278,8 +355,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readSubscription_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -292,8 +369,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = try repository - .readSubscription(#require(existingValue.managedId), of: modelType) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository + .readSubscription(#require(existingValue.managedId), of: modelType) + } + } else { + try repository + .readSubscription(#require(existingValue.managedId), of: modelType) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -313,8 +397,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -327,8 +411,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = try repository - .readThrowingSubscription(#require(existingValue.managedId), of: modelType) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository + .readThrowingSubscription(#require(existingValue.managedId), of: modelType) + } + } else { + try repository + .readThrowingSubscription(#require(existingValue.managedId), of: modelType) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) @@ -348,8 +439,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func read_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -362,12 +453,21 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - _ = try await repository - .read(existingValue).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(existingValue).get() + } + } else { + try await repository + .read(existingValue).get() + } + + expectNoDifference(value, existingValue) } - @Test - func read_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -384,8 +484,16 @@ extension CoreDataRepositoryTests { return value } - let result = await repository - .read(existingValue) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValue) + } + } else { + await repository + .read(existingValue) + } switch result { case .success: @@ -397,12 +505,20 @@ extension CoreDataRepositoryTests { } } - @Test - func read_ManagedIdReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .read(_value) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_value) + } + } else { + await repository + .read(_value) + } switch result { case .success: @@ -414,8 +530,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readSubscription_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -428,8 +544,15 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readSubscription(existingValue) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readSubscription(existingValue) + } + } else { + repository + .readSubscription(existingValue) + } var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -449,8 +572,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -463,8 +586,16 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readThrowingSubscription(existingValue) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readThrowingSubscription(existingValue) + } + } else { + repository + .readThrowingSubscription(existingValue) + } + var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) @@ -484,8 +615,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func read_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -498,12 +629,21 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - _ = try await repository - .read(#require(existingValue.managedIdUrl), of: modelType).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(#require(existingValue.managedIdUrl), of: modelType).get() + } + } else { + try await repository + .read(#require(existingValue.managedIdUrl), of: modelType).get() + } + + expectNoDifference(value, existingValue) } - @Test - func read_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -520,8 +660,16 @@ extension CoreDataRepositoryTests { return value } - let result = try await repository - .read(#require(existingValue.managedIdUrl), of: modelType) + + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(#require(existingValue.managedIdUrl), of: modelType) + } + } else { + try await repository + .read(#require(existingValue.managedIdUrl), of: modelType) + } switch result { case .success: @@ -533,8 +681,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readSubscription_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -547,8 +695,16 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = try repository - .readSubscription(#require(existingValue.managedIdUrl), of: modelType) + let stream = if inTransaction { + try await repository.withTransaction { _ in + try repository + .readSubscription(#require(existingValue.managedIdUrl), of: modelType) + } + } else { + try repository + .readSubscription(#require(existingValue.managedIdUrl), of: modelType) + } + var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -568,8 +724,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -582,8 +738,16 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readThrowingSubscription(existingValue) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readThrowingSubscription(existingValue) + } + } else { + repository + .readThrowingSubscription(existingValue) + } + var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) @@ -603,8 +767,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func read_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -617,12 +781,21 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - _ = try await repository - .read(existingValue).get() + let value = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(existingValue).get() + } + } else { + try await repository + .read(existingValue).get() + } + + expectNoDifference(value, existingValue) } - @Test - func read_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) let existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -639,8 +812,16 @@ extension CoreDataRepositoryTests { return value } - let result = await repository - .read(existingValue) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValue) + } + } else { + await repository + .read(existingValue) + } switch result { case .success: @@ -652,12 +833,20 @@ extension CoreDataRepositoryTests { } } - @Test - func read_ManagedIdUrlReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .read(_value) + + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_value) + } + } else { + await repository + .read(_value) + } switch result { case .success: @@ -669,8 +858,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readSubscription_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readSubscription_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -683,8 +872,16 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readSubscription(existingValue) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readSubscription(existingValue) + } + } else { + repository + .readSubscription(existingValue) + } + var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next()?.get() var latestValue = try #require(_latestValue) @@ -704,8 +901,8 @@ extension CoreDataRepositoryTests { expectNoDifference(latestValue, existingValue) } - @Test - func readThrowingSubscription_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readThrowingSubscription_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var (existingValue, managed) = try await repositoryContext.perform(schedule: .immediate) { @@ -718,8 +915,16 @@ extension CoreDataRepositoryTests { try await verify(existingValue) - let stream = repository - .readThrowingSubscription(existingValue) + let stream = if inTransaction { + try await repository.withTransaction { _ in + repository + .readThrowingSubscription(existingValue) + } + } else { + repository + .readThrowingSubscription(existingValue) + } + var iterator = stream.makeAsyncIterator() var _latestValue = try await iterator.next() var latestValue = try #require(_latestValue) diff --git a/Tests/CoreDataRepositoryTests/Read_BatchTests.swift b/Tests/CoreDataRepositoryTests/Read_BatchTests.swift index 50ddf27..a6e2499 100644 --- a/Tests/CoreDataRepositoryTests/Read_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Read_BatchTests.swift @@ -19,8 +19,8 @@ extension CoreDataRepositoryTests { // MARK: Non Atomic - @Test - func read_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -41,15 +41,22 @@ extension CoreDataRepositoryTests { try await verify(value) } - let (successful, failed) = await repository - .read(existingValues) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValues) + } + } else { + await repository + .read(existingValues) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) } - @Test - func read_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func read_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -58,15 +65,22 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let (successful, failed) = await repository - .read(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_values) + } + } else { + await repository + .read(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func read_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -87,15 +101,22 @@ extension CoreDataRepositoryTests { try await verify(value) } - let (successful, failed) = await repository - .read(existingValues) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValues) + } + } else { + await repository + .read(existingValues) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) } - @Test - func read_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -119,15 +140,22 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = await repository - .read(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_values) + } + } else { + await repository + .read(_values) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) } - @Test - func read_ManagedIdReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -138,15 +166,22 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let (successful, failed) = await repository - .read(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_values) + } + } else { + await repository + .read(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func read_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -167,15 +202,22 @@ extension CoreDataRepositoryTests { try await verify(value) } - let (successful, failed) = await repository - .read(existingValues.compactMap(\.managedId), as: modelType) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValues.compactMap(\.managedId), as: modelType) + } + } else { + await repository + .read(existingValues.compactMap(\.managedId), as: modelType) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) } - @Test - func read_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -204,15 +246,22 @@ extension CoreDataRepositoryTests { } try await verifyDoesNotExist(_values[0]) - let (successful, failed) = try await repository - .read(_values.map { try #require($0.managedId) }, as: modelType) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(_values.map { try #require($0.managedId) }, as: modelType) + } + } else { + try await repository + .read(_values.map { try #require($0.managedId) }, as: modelType) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) } - @Test - func read_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -233,15 +282,22 @@ extension CoreDataRepositoryTests { try await verify(value) } - let (successful, failed) = await repository - .read(existingValues) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValues) + } + } else { + await repository + .read(existingValues) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) } - @Test - func read_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -265,15 +321,22 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = await repository - .read(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_values) + } + } else { + await repository + .read(_values) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) } - @Test - func read_ManagedIdUrlReferencable_NoManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrlReferencable_NoManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -284,15 +347,22 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let (successful, failed) = await repository - .read(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(_values) + } + } else { + await repository + .read(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) } - @Test - func read_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -313,15 +383,22 @@ extension CoreDataRepositoryTests { try await verify(value) } - let (successful, failed) = await repository - .read(existingValues.compactMap(\.managedIdUrl), as: modelType) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .read(existingValues.compactMap(\.managedIdUrl), as: modelType) + } + } else { + await repository + .read(existingValues.compactMap(\.managedIdUrl), as: modelType) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) } - @Test - func read_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func read_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -345,8 +422,15 @@ extension CoreDataRepositoryTests { return values } - let (successful, failed) = try await repository - .read(_values.map { try #require($0.managedIdUrl) }, as: modelType) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .read(_values.map { try #require($0.managedIdUrl) }, as: modelType) + } + } else { + try await repository + .read(_values.map { try #require($0.managedIdUrl) }, as: modelType) + } expectNoDifference(successful.count, _values.count - 1) expectNoDifference(failed.count, 1) @@ -354,8 +438,8 @@ extension CoreDataRepositoryTests { // MARK: Atomic - @Test - func readAtomically_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func readAtomically_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -376,14 +460,21 @@ extension CoreDataRepositoryTests { try await verify(value) } - let values = try await repository - .readAtomically(existingValues).get() + let values = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(existingValues).get() + } + } else { + try await repository + .readAtomically(existingValues).get() + } expectNoDifference(values, existingValues) } - @Test - func readAtomically_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -392,8 +483,15 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let result = await repository - .readAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .readAtomically(_values) + } + } else { + await repository + .readAtomically(_values) + } switch result { case .success: @@ -405,8 +503,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -427,14 +525,21 @@ extension CoreDataRepositoryTests { try await verify(value) } - let values = try await repository - .readAtomically(existingValues).get() + let values = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(existingValues).get() + } + } else { + try await repository + .readAtomically(existingValues).get() + } expectNoDifference(values, existingValues) } - @Test - func readAtomically_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -458,8 +563,15 @@ extension CoreDataRepositoryTests { return values } - let result = await repository - .readAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .readAtomically(_values) + } + } else { + await repository + .readAtomically(_values) + } switch result { case .success: @@ -471,8 +583,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedIdReferencable_NoManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdReferencable_NoManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ @@ -483,8 +595,15 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let result = await repository - .readAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .readAtomically(_values) + } + } else { + await repository + .readAtomically(_values) + } switch result { case .success: @@ -496,8 +615,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = [ modelType.seeded(1), @@ -518,14 +637,21 @@ extension CoreDataRepositoryTests { try await verify(value) } - let values = try await repository - .readAtomically(existingValues.compactMap(\.managedId), as: modelType).get() + let values = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(existingValues.compactMap(\.managedId), as: modelType).get() + } + } else { + try await repository + .readAtomically(existingValues.compactMap(\.managedId), as: modelType).get() + } expectNoDifference(values, existingValues) } - @Test - func readAtomically_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -554,8 +680,15 @@ extension CoreDataRepositoryTests { } try await verifyDoesNotExist(_values[0]) - let result = try await repository - .readAtomically(_values.map { try #require($0.managedId) }, as: modelType) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(_values.map { try #require($0.managedId) }, as: modelType) + } + } else { + try await repository + .readAtomically(_values.map { try #require($0.managedId) }, as: modelType) + } switch result { case .success: @@ -567,8 +700,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -589,14 +722,21 @@ extension CoreDataRepositoryTests { try await verify(value) } - let values = try await repository - .readAtomically(existingValues).get() + let values = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(existingValues).get() + } + } else { + try await repository + .readAtomically(existingValues).get() + } expectNoDifference(values, existingValues) } - @Test - func readAtomically_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -620,8 +760,15 @@ extension CoreDataRepositoryTests { return values } - let result = await repository - .readAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .readAtomically(_values) + } + } else { + await repository + .readAtomically(_values) + } switch result { case .success: @@ -633,8 +780,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedIdUrlReferencable_NoManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdUrlReferencable_NoManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ @@ -645,8 +792,15 @@ extension CoreDataRepositoryTests { modelType.seeded(5), ] - let result = await repository - .readAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .readAtomically(_values) + } + } else { + await repository + .readAtomically(_values) + } switch result { case .success: @@ -658,8 +812,8 @@ extension CoreDataRepositoryTests { } } - @Test - func readAtomically_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = [ modelType.seeded(1), @@ -680,14 +834,21 @@ extension CoreDataRepositoryTests { try await verify(value) } - let values = try await repository - .readAtomically(existingValues.compactMap(\.managedIdUrl), as: modelType).get() + let values = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(existingValues.compactMap(\.managedIdUrl), as: modelType).get() + } + } else { + try await repository + .readAtomically(existingValues.compactMap(\.managedIdUrl), as: modelType).get() + } expectNoDifference(values, existingValues) } - @Test - func readAtomically_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func readAtomically_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _values = try await repositoryContext.perform(schedule: .immediate) { @@ -711,8 +872,15 @@ extension CoreDataRepositoryTests { return values } - let result = try await repository - .readAtomically(_values.map { try #require($0.managedIdUrl) }, as: modelType) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .readAtomically(_values.map { try #require($0.managedIdUrl) }, as: modelType) + } + } else { + try await repository + .readAtomically(_values.map { try #require($0.managedIdUrl) }, as: modelType) + } switch result { case .success: diff --git a/Tests/CoreDataRepositoryTests/TransactionTests.swift b/Tests/CoreDataRepositoryTests/TransactionTests.swift new file mode 100644 index 0000000..14f19e8 --- /dev/null +++ b/Tests/CoreDataRepositoryTests/TransactionTests.swift @@ -0,0 +1,447 @@ +// TransactionTests.swift +// CoreDataRepository +// +// This source code is licensed under the MIT License (MIT) found in the +// LICENSE file in the root directory of this source tree. + +import CoreData +import CoreDataRepository +import CustomDump +import Internal +import Testing + +extension CoreDataRepositoryTests { + @Suite + struct TransactionTests: CoreDataTestSuite { + let container: NSPersistentContainer + let repositoryContext: NSManagedObjectContext + let repository: CoreDataRepository + + @Test + func transaction_Success() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let wrongTransactionAuthor = "WRONG_AUTHOR" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { _ in + let value1 = try await repository + .create(_value1, transactionAuthor: wrongTransactionAuthor).get() + let value2 = try await repository + .create(_value2, transactionAuthor: wrongTransactionAuthor).get() + let value3 = try await repository + .create(_value3, transactionAuthor: wrongTransactionAuthor).get() + return (value1, value2, value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + try await verify(value1) + try await verify(value2) + try await verify(value3) + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + // In transactions individual endpoint transactionAuthor is ignored + try verifyDoesNotExist(transactionAuthor: wrongTransactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transaction_Failure() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date.now + let transactionAuthor = #function + + let expectedError = NSError( + domain: "NSCocoaErrorDomain", + code: 133_021, + userInfo: [ + "NSExceptionOmitCallstacks": 1, + "conflictList": [ + NSConstraintConflict(), + ], + ] + ) + + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(2) + do { + try await repository + .withTransaction(transactionAuthor: transactionAuthor) { _ in + _ = try await repository + .create(_value1).get() + _ = try await repository + .create(_value2).get() + _ = try await repository + .create(_value3).get() + } + } catch { + switch error { + case let .cocoa(_cocoaError): + let nsError = _cocoaError as NSError + expectNoDifference(nsError.code, expectedError.code) + expectNoDifference(nsError.domain, expectedError.domain) + case let .unknown(nsError): + expectNoDifference(nsError, NSError(domain: "", code: -1)) + default: + Issue.record() + } + } + + try await verifyDoesNotExist(_value1) + try await verifyDoesNotExist(_value2) + try await verifyDoesNotExist(_value3) + + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transaction_Cancel() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date.now + let transactionAuthor: String = #function + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { transaction in + let value1 = try await repository + .create(_value1).get() + let value2 = try await repository + .create(_value2).get() + let value3 = try await repository + .create(_value3).get() + transaction.cancel() + return (value1, value2, value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + try await verifyDoesNotExist(value1) + try await verifyDoesNotExist(value2) + try await verifyDoesNotExist(value3) + + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func concurrentTransactions_OneFailsOtherSucceeds() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date.now + let successAuthor = "SuccessfulTransaction" + let failureAuthor = "FailedTransaction" + + let successValue1 = modelType.seeded(1) + let successValue2 = modelType.seeded(2) + let failureValue1 = modelType.seeded(3) + let failureValue2 = modelType.seeded(3) // Duplicate ID to cause failure + + let repository = repository + + // Use actors to coordinate timing between transactions + actor Coordinator { + private var successStarted = false + private var failureFinished = false + + func successDidStart() { + successStarted = true + } + + func waitForSuccessToStart() async { + while !successStarted { + await Task.yield() + } + } + + func failureDidFinish() { + failureFinished = true + } + + func waitForFailureToFinish() async { + while !failureFinished { + await Task.yield() + } + } + } + + let coordinator = Coordinator() + + async let successTask = repository.withTransaction(transactionAuthor: successAuthor) { _ in + await coordinator.successDidStart() + let value1 = try await repository.create(successValue1).get() + let value2 = try await repository.create(successValue2).get() + // Wait for failure task to complete first + await coordinator.waitForFailureToFinish() + return (value1, value2) + } + + async let failureTask: (FetchableModel_UuidId, FetchableModel_UuidId) = { + do { + let result = try await repository.withTransaction(transactionAuthor: failureAuthor) { _ in + // Wait for success task to start + await coordinator.waitForSuccessToStart() + let value1 = try await repository.create(failureValue1).get() + let value2 = try await repository.create(failureValue2).get() // This should fail + return (value1, value2) + } + await coordinator.failureDidFinish() + return result + } catch { + await coordinator.failureDidFinish() + // Expected to fail due to duplicate ID constraint + throw error + } + }() + + // Wait for both transactions to complete and handle results + do { + let (value1, value2) = try await successTask + expectNoDifference(value1, successValue1) + expectNoDifference(value2, successValue2) + try await verify(value1) + try await verify(value2) + try verify(transactionAuthor: successAuthor, timeStamp: historyTimeStamp) + } catch { + Issue.record("Successful transaction should not have failed: \(error)") + } + + do { + _ = try await failureTask + Issue.record("Failure transaction should have failed") + } catch { + // Expected to fail due to duplicate ID constraint + try await verifyDoesNotExist(failureValue1) + try await verifyDoesNotExist(failureValue2) + try verifyDoesNotExist(transactionAuthor: failureAuthor, timeStamp: historyTimeStamp) + } + } + + @Test + func transactionAcrossTasks() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let wrongTransactionAuthor = "WRONG_AUTHOR" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + + let repository = repository + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { _ in + let value1 = try await repository + .create(_value1, transactionAuthor: wrongTransactionAuthor).get() + let value2 = try await repository + .create(_value2, transactionAuthor: wrongTransactionAuthor).get() + let value3 = try await Task { + try await repository + .create(_value3, transactionAuthor: wrongTransactionAuthor).get() + }.value + return (value1, value2, value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + try await verify(value1) + try await verify(value2) + try await verify(value3) + + try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + // In transactions individual endpoint transactionAuthor is ignored + try verifyDoesNotExist(transactionAuthor: wrongTransactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transactionAcrossDetachedTasks() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let detachedTaskTransactionAuthor = "\(#function)_detached_task" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + + let repository = repository + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { transaction in + let value1 = try await repository + .create(_value1).get() + let value2 = try await repository + .create(_value2).get() + let value3 = try await Task.detached { + try await repository + .create(_value3, transactionAuthor: detachedTaskTransactionAuthor).get() + }.value + transaction.cancel() + return (value1, value2, value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + // `value3` was created in a detached task which made it NOT part of the transaction + await withKnownIssue { + try await verify(value1) + try await verify(value2) + } + try await verify(value3) + + try verify(transactionAuthor: detachedTaskTransactionAuthor, timeStamp: historyTimeStamp) + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transactionAcrossDispatchQueue() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let detachedTaskTransactionAuthor = "\(#function)_detached_task" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + + let dispatchQueue = DispatchQueue(label: "value_3", qos: .userInitiated) + + let repository = repository + + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { transaction in + let value1 = try await repository + .create(_value1).get() + let value2 = try await repository + .create(_value2).get() + await withCheckedContinuation { continuation in + dispatchQueue.async { + Task { + _ = try await repository + .create(_value3, transactionAuthor: detachedTaskTransactionAuthor).get() + continuation.resume() + } + } + } + transaction.cancel() + return (value1, value2, _value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + // `value3` was created in a DispatchQueue which made it NOT part of the transaction + await withKnownIssue { + try await verify(value1) + try await verify(value2) + } + try await verify(value3) + + try verify(transactionAuthor: detachedTaskTransactionAuthor, timeStamp: historyTimeStamp) + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transactionAcrossDetachedTasks_ContinuingTransaction() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let detachedTaskTransactionAuthor = "\(#function)_detached_task" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + + let repository = repository + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { transaction in + let value1 = try await repository + .create(_value1).get() + let value2 = try await repository + .create(_value2).get() + let value3 = try await Task.detached { + try await repository.withTransaction(continuing: transaction) { _ in + try await repository + .create(_value3, transactionAuthor: detachedTaskTransactionAuthor).get() + } + }.value + transaction.cancel() + return (value1, value2, value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + try await verifyDoesNotExist(value1) + try await verifyDoesNotExist(value2) + try await verifyDoesNotExist(value3) + + try verifyDoesNotExist(transactionAuthor: detachedTaskTransactionAuthor, timeStamp: historyTimeStamp) + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + @Test + func transactionAcrossDispatchQueue_ContinuingTransaction() async throws { + let modelType = FetchableModel_UuidId.self + let historyTimeStamp = Date() + let transactionAuthor: String = #function + let detachedTaskTransactionAuthor = "\(#function)_detached_task" + let _value1 = modelType.seeded(1) + let _value2 = modelType.seeded(2) + let _value3 = modelType.seeded(3) + + let dispatchQueue = DispatchQueue(label: "value_3", qos: .userInitiated) + + let repository = repository + + let (value1, value2, value3) = try await repository + .withTransaction(transactionAuthor: transactionAuthor) { transaction in + let value1 = try await repository + .create(_value1).get() + let value2 = try await repository + .create(_value2).get() + await withCheckedContinuation { continuation in + dispatchQueue.async { + Task { + try await repository.withTransaction(continuing: transaction) { _ in + _ = try await repository + .create(_value3, transactionAuthor: detachedTaskTransactionAuthor).get() + } + continuation.resume() + } + } + } + transaction.cancel() + return (value1, value2, _value3) + } + + expectNoDifference(value1, _value1) + expectNoDifference(value2, _value2) + expectNoDifference(value3, _value3) + + try await verifyDoesNotExist(value1) + try await verifyDoesNotExist(value2) + try await verifyDoesNotExist(value3) + + try verifyDoesNotExist(transactionAuthor: detachedTaskTransactionAuthor, timeStamp: historyTimeStamp) + try verifyDoesNotExist(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) + } + + init( + container: NSPersistentContainer, + repositoryContext: NSManagedObjectContext, + repository: CoreDataRepository + ) { + self.container = container + self.repositoryContext = repositoryContext + self.repository = repository + } + } +} diff --git a/Tests/CoreDataRepositoryTests/UpdateTests.swift b/Tests/CoreDataRepositoryTests/UpdateTests.swift index 6f4352a..dc2be19 100644 --- a/Tests/CoreDataRepositoryTests/UpdateTests.swift +++ b/Tests/CoreDataRepositoryTests/UpdateTests.swift @@ -17,8 +17,8 @@ extension CoreDataRepositoryTests { let repositoryContext: NSManagedObjectContext let repository: CoreDataRepository - @Test - func update_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func update_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -37,8 +37,15 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let updatedValue = try await repository - .update(with: existingValue, transactionAuthor: transactionAuthor).get() + let updatedValue = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .update(with: existingValue).get() + } + } else { + try await repository + .update(with: existingValue, transactionAuthor: transactionAuthor).get() + } expectNoDifference(updatedValue, existingValue) @@ -46,12 +53,19 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func update_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .update(with: _value) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .update(with: _value) + } + } else { + await repository + .update(with: _value) + } switch result { case .success: @@ -63,8 +77,8 @@ extension CoreDataRepositoryTests { } } - @Test - func update_ManagedIdReferencable_Success() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -82,8 +96,15 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let updatedValue = try await repository - .update(with: existingValue, transactionAuthor: transactionAuthor).get() + let updatedValue = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .update(with: existingValue).get() + } + } else { + try await repository + .update(with: existingValue, transactionAuthor: transactionAuthor).get() + } expectNoDifference(updatedValue, existingValue) @@ -91,12 +112,19 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_ManagedIdReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .update(with: _value) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .update(with: _value) + } + } else { + await repository + .update(with: _value) + } switch result { case .success: @@ -108,8 +136,8 @@ extension CoreDataRepositoryTests { } } - @Test - func update_ManagedId_Success() async throws { + @Test(arguments: [false, true]) + func update_ManagedId_Success(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -128,9 +156,21 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let updatedValue = try await repository - .update(#require(existingValue.managedId), with: existingValue, transactionAuthor: transactionAuthor) - .get() + let updatedValue = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .update(#require(existingValue.managedId), with: existingValue) + .get() + } + } else { + try await repository + .update( + #require(existingValue.managedId), + with: existingValue, + transactionAuthor: transactionAuthor + ) + .get() + } expectNoDifference(updatedValue, existingValue) @@ -138,8 +178,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_ManagedId_Failure() async throws { + @Test(arguments: [false, true]) + func update_ManagedId_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -160,8 +200,15 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let result = try await repository - .update(#require(existingValue.managedId), with: existingValue) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .update(#require(existingValue.managedId), with: existingValue) + } + } else { + try await repository + .update(#require(existingValue.managedId), with: existingValue) + } switch result { case .success: @@ -173,8 +220,8 @@ extension CoreDataRepositoryTests { } } - @Test - func update_ManagedIdUrlReferencable_Success() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdUrlReferencable_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -192,8 +239,15 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let updatedValue = try await repository - .update(with: existingValue, transactionAuthor: transactionAuthor).get() + let updatedValue = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .update(with: existingValue).get() + } + } else { + try await repository + .update(with: existingValue, transactionAuthor: transactionAuthor).get() + } expectNoDifference(updatedValue, existingValue) @@ -201,12 +255,19 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_ManagedIdUrlReferencable_Failure() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdUrlReferencable_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) - let result = await repository - .update(with: _value) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .update(with: _value) + } + } else { + await repository + .update(with: _value) + } switch result { case .success: @@ -218,8 +279,8 @@ extension CoreDataRepositoryTests { } } - @Test - func update_ManagedIdUrl_Success() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdUrl_Success(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -238,13 +299,24 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let updatedValue = try await repository - .update( - #require(existingValue.managedIdUrl), - with: existingValue, - transactionAuthor: transactionAuthor - ) - .get() + let updatedValue = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + try await repository + .update( + #require(existingValue.managedIdUrl), + with: existingValue + ) + .get() + } + } else { + try await repository + .update( + #require(existingValue.managedIdUrl), + with: existingValue, + transactionAuthor: transactionAuthor + ) + .get() + } expectNoDifference(updatedValue, existingValue) @@ -252,8 +324,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_ManagedIdUrl_Failure() async throws { + @Test(arguments: [false, true]) + func update_ManagedIdUrl_Failure(inTransaction: Bool) async throws { let modelType = ManagedIdUrlModel_UuidId.self let _value = modelType.seeded(1) var existingValue = try await repositoryContext.perform(schedule: .immediate) { @@ -274,8 +346,15 @@ extension CoreDataRepositoryTests { existingValue.bool.toggle() - let result = try await repository - .update(#require(existingValue.managedIdUrl), with: existingValue) + let result = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .update(#require(existingValue.managedIdUrl), with: existingValue) + } + } else { + try await repository + .update(#require(existingValue.managedIdUrl), with: existingValue) + } switch result { case .success: diff --git a/Tests/CoreDataRepositoryTests/Update_BatchTests.swift b/Tests/CoreDataRepositoryTests/Update_BatchTests.swift index ce3d307..932216f 100644 --- a/Tests/CoreDataRepositoryTests/Update_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Update_BatchTests.swift @@ -19,8 +19,8 @@ extension CoreDataRepositoryTests { // MARK: Non Atomic - @Test - func update_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func update_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -50,8 +50,15 @@ extension CoreDataRepositoryTests { return value } - let (successful, failed) = await repository - .update(existingValues, transactionAuthor: transactionAuthor) + let (successful, failed) = if inTransaction { + try await repository.withTransaction(transactionAuthor: transactionAuthor) { _ in + await repository + .update(existingValues) + } + } else { + await repository + .update(existingValues, transactionAuthor: transactionAuthor) + } expectNoDifference(successful.count, _values.count) expectNoDifference(failed.count, 0) @@ -61,8 +68,8 @@ extension CoreDataRepositoryTests { try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) } - @Test - func update_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func update_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -71,8 +78,15 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let (successful, failed) = await repository - .update(_values) + let (successful, failed) = if inTransaction { + try await repository.withTransaction { _ in + await repository + .update(_values) + } + } else { + await repository + .update(_values) + } expectNoDifference(successful.count, 0) expectNoDifference(failed.count, _values.count) @@ -80,8 +94,8 @@ extension CoreDataRepositoryTests { // MARK: Atomic - @Test - func updateAtomically_Identifiable_Success() async throws { + @Test(arguments: [false, true]) + func updateAtomically_Identifiable_Success(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -102,16 +116,23 @@ extension CoreDataRepositoryTests { try await verify(value) } - let updatedValues = try await repository - .updateAtomically(existingValues).get() + let updatedValues = if inTransaction { + try await repository.withTransaction { _ in + try await repository + .updateAtomically(existingValues).get() + } + } else { + try await repository + .updateAtomically(existingValues).get() + } for value in updatedValues { try await verify(value) } } - @Test - func updateAtomically_Identifiable_Failure() async throws { + @Test(arguments: [false, true]) + func updateAtomically_Identifiable_Failure(inTransaction: Bool) async throws { let modelType = IdentifiableModel_UuidId.self let _values = [ modelType.seeded(1), @@ -120,8 +141,15 @@ extension CoreDataRepositoryTests { modelType.seeded(4), modelType.seeded(5), ] - let result = await repository - .updateAtomically(_values) + let result = if inTransaction { + try await repository.withTransaction { _ in + await repository + .updateAtomically(_values) + } + } else { + await repository + .updateAtomically(_values) + } switch result { case .success: