From 516d16ac3c9b10adcfdc91f4c5232572864dbb9d Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:02:48 -0500 Subject: [PATCH 01/10] Conform CoreDataRepository to Sendable feature/sendable --- Sources/CoreDataRepository/CoreDataRepository.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CoreDataRepository/CoreDataRepository.swift b/Sources/CoreDataRepository/CoreDataRepository.swift index b75a155..a8697da 100644 --- a/Sources/CoreDataRepository/CoreDataRepository.swift +++ b/Sources/CoreDataRepository/CoreDataRepository.swift @@ -22,7 +22,10 @@ import Foundation /// For fetch and aggregate operations, there are additional subscription and throwing subscription options. /// Subscriptions return an ``AsyncStream`` of /// ``Result``s with strongly typed errors. Throwing subscriptions return an ``AsyncThrowingStream``. -public final class CoreDataRepository { +/// +/// All uses of ``context`` are wrapped in `perform` or `performAndWait` blocks so ``CoreDataRepository`` is concurrency +/// safe. +public final class CoreDataRepository: @unchecked Sendable { /// CoreData context the repository uses. A child or 'scratch' context is usually created from this context for work /// to be performed in. public let context: NSManagedObjectContext From a0b79df1d491710dc26faef89e0012b2f7e2a6a4 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:29:47 -0500 Subject: [PATCH 02/10] Fix StrictConcurrency flag feature/sendable --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index e4e474d..f698874 100644 --- a/Package.swift +++ b/Package.swift @@ -76,7 +76,7 @@ extension [SwiftSetting] { .enableUpcomingFeature("DisableOutwardActorInference"), .enableUpcomingFeature("ForwardTrailingClosures"), .enableUpcomingFeature("ImportObjcForwardDeclarations"), - .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("StrictConcurrency"), ] } From 9163b398a94d5849ba68a022af264a1a9d77cdbb Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:30:55 -0500 Subject: [PATCH 03/10] Update swift version for swiftformat feature/sendable --- .swiftformat | 2 +- Package.swift | 40 ++++++----- .../CoreDataRepository/CoreDataError.swift | 66 +++++++++---------- .../CoreDataRepository+Aggregate.swift | 4 +- 4 files changed, 55 insertions(+), 57 deletions(-) diff --git a/.swiftformat b/.swiftformat index ac9e597..89b6e70 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,6 +1,6 @@ --extensionacl on-declarations --redundanttype explicit ---swiftversion 5.8 +--swiftversion 5.10 --maxwidth 120 --header "{file}\nCoreDataRepository\n\nThis source code is licensed under the MIT License (MIT) found in the\nLICENSE file in the root directory of this source tree." --allman false diff --git a/Package.swift b/Package.swift index f698874..a827fb1 100644 --- a/Package.swift +++ b/Package.swift @@ -45,27 +45,25 @@ let package = Package( ) extension [SupportedPlatform] { - static let shared: Self = { - if ProcessInfo.benchmarkingEnabled { - [ - .iOS(.v15), - .macOS(.v12), - .tvOS(.v15), - .watchOS(.v8), - .macCatalyst(.v15), - .visionOS(.v1), - ] - } else { - [ - .iOS(.v15), - .macOS(.v13), - .tvOS(.v15), - .watchOS(.v8), - .macCatalyst(.v15), - .visionOS(.v1), - ] - } - }() + static let shared: Self = if ProcessInfo.benchmarkingEnabled { + [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8), + .macCatalyst(.v15), + .visionOS(.v1), + ] + } else { + [ + .iOS(.v15), + .macOS(.v13), + .tvOS(.v15), + .watchOS(.v8), + .macCatalyst(.v15), + .visionOS(.v1), + ] + } } extension [SwiftSetting] { diff --git a/Sources/CoreDataRepository/CoreDataError.swift b/Sources/CoreDataRepository/CoreDataError.swift index c47dc07..7bc10d9 100644 --- a/Sources/CoreDataRepository/CoreDataError.swift +++ b/Sources/CoreDataRepository/CoreDataError.swift @@ -59,13 +59,13 @@ public enum CoreDataError: Error, Hashable, Sendable { public var localizedDescription: String { switch self { case .failedToGetObjectIdFromUrl: - return NSLocalizedString( + NSLocalizedString( "No NSManagedObjectID found that correlates to the provided URL.", bundle: .module, comment: "Error for when an ObjectID can't be found for the provided URL." ) case .propertyDoesNotMatchEntity: - return NSLocalizedString( + NSLocalizedString( "There is a mismatch between a provided NSPropertyDescrption's entity and a NSEntityDescription. " + "When a property description is provided, it must match any related entity descriptions.", bundle: .module, @@ -73,51 +73,51 @@ public enum CoreDataError: Error, Hashable, Sendable { + "and NSPropertyDescription (or any of their child types)." ) case .fetchedObjectFailedToCastToExpectedType: - return NSLocalizedString( + NSLocalizedString( "The object corresponding to the provided NSManagedObjectID is an incorrect Entity or " + "NSManagedObject subtype. It failed to cast to the requested type.", bundle: .module, comment: "Error for when an object is found for a given ObjectID but it is not the expected type." ) case .fetchedObjectIsFlaggedAsDeleted: - return NSLocalizedString( + NSLocalizedString( "The object corresponding to the provided NSManagedObjectID is deleted and cannot be fetched.", bundle: .module, comment: "Error for when an object is fetched but is flagged as deleted and is no longer usable." ) case let .cocoa(error): - return error.localizedDescription + error.localizedDescription case let .unknown(error): - return error.localizedDescription + error.localizedDescription case .noEntityNameFound: - return NSLocalizedString( + NSLocalizedString( "The managed object entity description does not have a name.", bundle: .module, comment: "Error for when the NSEntityDescription does not have a name." ) case .atLeastOneAttributeDescRequired: - return NSLocalizedString( + NSLocalizedString( "The managed object entity has no attribute description. An attribute description is required for " + "aggregate operations.", bundle: .module, comment: "Error for when the NSEntityDescription has no NSAttributeDescription but one is required." ) case .noUrlOnItemToMapToObjectId: - return NSLocalizedString( + NSLocalizedString( "No object ID URL found on the model for an operation against an existing managed object.", bundle: .module, comment: "Error for performing an operation against an existing NSManagedObject but the " + "ManagedIdUrlReferencable instance has no managedIdUrl for looking up the NSManagedOjbectID." ) case .noObjectIdOnItem: - return NSLocalizedString( + NSLocalizedString( "No object ID found on the model for an operation against an existing managed object.", bundle: .module, comment: "Error for performing an operation against an existing NSManagedObject but the " + "ManagedIdReferencable instance has no managedId." ) case .noMatchFoundWhenReadingItem: - return NSLocalizedString( + NSLocalizedString( "No match found when attempting to read an instance from CoreData.", bundle: .module, comment: "Error for reading an instance from CoreData but no instance was found." @@ -133,27 +133,27 @@ extension CoreDataError: CustomNSError { public var errorCode: Int { switch self { case .failedToGetObjectIdFromUrl: - return 1 + 1 case .propertyDoesNotMatchEntity: - return 2 + 2 case .fetchedObjectFailedToCastToExpectedType: - return 3 + 3 case .fetchedObjectIsFlaggedAsDeleted: - return 4 + 4 case .cocoa: - return 5 + 5 case .unknown: - return 6 + 6 case .noEntityNameFound: - return 7 + 7 case .atLeastOneAttributeDescRequired: - return 8 + 8 case .noUrlOnItemToMapToObjectId: - return 9 + 9 case .noObjectIdOnItem: - return 10 + 10 case .noMatchFoundWhenReadingItem: - return 11 + 11 } } @@ -163,27 +163,27 @@ extension CoreDataError: CustomNSError { public var errorUserInfo: [String: Any] { switch self { case let .failedToGetObjectIdFromUrl(url): - return [Self.urlUserInfoKey: url] + [Self.urlUserInfoKey: url] case .propertyDoesNotMatchEntity: - return [:] + [:] case .fetchedObjectFailedToCastToExpectedType: - return [:] + [:] case .fetchedObjectIsFlaggedAsDeleted: - return [:] + [:] case let .cocoa(error): - return error.userInfo + error.userInfo case let .unknown(error): - return error.userInfo + error.userInfo case .noEntityNameFound: - return [:] + [:] case .atLeastOneAttributeDescRequired: - return [:] + [:] case .noUrlOnItemToMapToObjectId: - return [:] + [:] case .noObjectIdOnItem: - return [:] + [:] case .noMatchFoundWhenReadingItem: - return [:] + [:] } } } diff --git a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift index fef07cd..a0cab19 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift @@ -29,9 +29,9 @@ extension CoreDataRepository { ) async -> Result { switch function { case .count: - return await count(predicate: predicate, entityDesc: entityDesc, as: valueType) + await count(predicate: predicate, entityDesc: entityDesc, as: valueType) default: - return await Self.send( + await Self.send( function: function, context: context, predicate: predicate, From 38abaaaab0d23b79b58cada60c7f00c200e03610 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:32:31 -0500 Subject: [PATCH 04/10] Fix some concurrency warnings with subscriptions feature/sendable --- .../CoreDataRepository+Aggregate.swift | 70 +++++++++---------- .../Internal/AggregateSubscription.swift | 4 +- .../AggregateThrowingSubscription.swift | 2 +- .../Internal/CountSubscription.swift | 4 +- .../Internal/CountThrowingSubscription.swift | 2 +- .../Internal/Subscription.swift | 2 +- .../Internal/ThrowingSubscription.swift | 2 +- 7 files changed, 45 insertions(+), 41 deletions(-) diff --git a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift index a0cab19..d4bcb92 100644 --- a/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift +++ b/Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift @@ -19,14 +19,14 @@ extension CoreDataRepository { } @inlinable - public func aggregate( + public func aggregate( function: AggregateFunction, predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as valueType: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { switch function { case .count: await count(predicate: predicate, entityDesc: entityDesc, as: valueType) @@ -46,13 +46,13 @@ extension CoreDataRepository { /// Get the average of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func average( + public func average( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .average, context: context, @@ -65,13 +65,13 @@ extension CoreDataRepository { /// Subscribe to the average of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func averageSubscription( + public func averageSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncStream> { + ) -> AsyncStream> where Value: Numeric, Value: Sendable { AsyncStream { continuation in let subscription = AggregateSubscription( function: .average, @@ -91,13 +91,13 @@ extension CoreDataRepository { /// Subscribe to the average of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func averageThrowingSubscription( + public func averageThrowingSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncThrowingStream { + ) -> AsyncThrowingStream where Value: Numeric, Value: Sendable { AsyncThrowingStream { continuation in let subscription = AggregateThrowingSubscription( function: .average, @@ -119,11 +119,11 @@ extension CoreDataRepository { /// Get the count or quantity of managed object instances that satisfy the predicate. @inlinable - public func count( + public func count( predicate: NSPredicate, entityDesc: NSEntityDescription, as _: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { await context.performInScratchPad { scratchPad in do { let request = try NSFetchRequest @@ -140,11 +140,11 @@ extension CoreDataRepository { /// Subscribe to the count or quantity of managed object instances that satisfy the predicate. @inlinable - public func countSubscription( + public func countSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, as _: Value.Type - ) -> AsyncStream> { + ) -> AsyncStream> where Value: Numeric, Value: Sendable { AsyncStream { continuation in let subscription = CountSubscription( context: context.childContext(), @@ -161,11 +161,11 @@ extension CoreDataRepository { /// Subscribe to the count or quantity of managed object instances that satisfy the predicate. @inlinable - public func countThrowingSubscription( + public func countThrowingSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, as _: Value.Type - ) -> AsyncThrowingStream { + ) -> AsyncThrowingStream where Value: Numeric, Value: Sendable { AsyncThrowingStream { continuation in let subscription = CountThrowingSubscription( context: context.childContext(), @@ -184,13 +184,13 @@ extension CoreDataRepository { /// Get the max or maximum of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func max( + public func max( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .max, context: context, @@ -204,13 +204,13 @@ extension CoreDataRepository { /// Subscribe to the max or maximum of a managed object's numeric property for all instances that satisfy the /// predicate. @inlinable - public func maxSubscription( + public func maxSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncStream> { + ) -> AsyncStream> where Value: Numeric, Value: Sendable { AsyncStream { continuation in let subscription = AggregateSubscription( function: .max, @@ -231,13 +231,13 @@ extension CoreDataRepository { /// Subscribe to the max or maximum of a managed object's numeric property for all instances that satisfy the /// predicate. @inlinable - public func maxThrowingSubscription( + public func maxThrowingSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncThrowingStream { + ) -> AsyncThrowingStream where Value: Numeric, Value: Sendable { AsyncThrowingStream { continuation in let subscription = AggregateThrowingSubscription( function: .max, @@ -259,13 +259,13 @@ extension CoreDataRepository { /// Get the min or minimum of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func min( + public func min( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .min, context: context, @@ -279,13 +279,13 @@ extension CoreDataRepository { /// Subscribe to the min or minimum of a managed object's numeric property for all instances that satisfy the /// predicate. @inlinable - public func minSubscription( + public func minSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncStream> { + ) -> AsyncStream> where Value: Numeric, Value: Sendable { AsyncStream { continuation in let subscription = AggregateSubscription( function: .min, @@ -306,13 +306,13 @@ extension CoreDataRepository { /// Subscribe to the min or minimum of a managed object's numeric property for all instances that satisfy the /// predicate. @inlinable - public func minThrowingSubscription( + public func minThrowingSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncThrowingStream { + ) -> AsyncThrowingStream where Value: Numeric, Value: Sendable { AsyncThrowingStream { continuation in let subscription = AggregateThrowingSubscription( function: .min, @@ -334,13 +334,13 @@ extension CoreDataRepository { /// Get the sum of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func sum( + public func sum( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) async -> Result { + ) async -> Result where Value: Numeric, Value: Sendable { await Self.send( function: .sum, context: context, @@ -353,13 +353,13 @@ extension CoreDataRepository { /// Subscribe to the sum of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func sumSubscription( + public func sumSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncStream> { + ) -> AsyncStream> where Value: Numeric, Value: Sendable { AsyncStream { continuation in let subscription = AggregateSubscription( function: .sum, @@ -379,13 +379,13 @@ extension CoreDataRepository { /// Subscribe to the sum of a managed object's numeric property for all instances that satisfy the predicate. @inlinable - public func sumThrowingSubscription( + public func sumThrowingSubscription( predicate: NSPredicate, entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil, as _: Value.Type - ) -> AsyncThrowingStream { + ) -> AsyncThrowingStream where Value: Numeric, Value: Sendable { AsyncThrowingStream { continuation in let subscription = AggregateThrowingSubscription( function: .sum, @@ -405,10 +405,10 @@ extension CoreDataRepository { // MARK: Internals - private static func aggregate( + private static func aggregate( context: NSManagedObjectContext, request: NSFetchRequest - ) throws -> Value { + ) throws -> Value where Value: Numeric, Value: Sendable { let result = try context.fetch(request) guard let value: Value = result.asAggregateValue() else { throw CoreDataError.fetchedObjectFailedToCastToExpectedType @@ -424,7 +424,7 @@ extension CoreDataRepository { entityDesc: NSEntityDescription, attributeDesc: NSAttributeDescription, groupBy: NSAttributeDescription? = nil - ) async -> Result where Value: Numeric { + ) async -> Result where Value: Numeric, Value: Sendable { guard entityDesc == attributeDesc.entity else { return .failure(.propertyDoesNotMatchEntity) } diff --git a/Sources/CoreDataRepository/Internal/AggregateSubscription.swift b/Sources/CoreDataRepository/Internal/AggregateSubscription.swift index 4ecf142..3b14e8c 100644 --- a/Sources/CoreDataRepository/Internal/AggregateSubscription.swift +++ b/Sources/CoreDataRepository/Internal/AggregateSubscription.swift @@ -9,7 +9,9 @@ import Foundation /// Subscription provider that sends updates when an aggregate fetch request changes @usableFromInline -final class AggregateSubscription: Subscription where Value: Numeric { +final class AggregateSubscription: Subscription where Value: Numeric, + Value: Sendable +{ @usableFromInline override func fetch() { frc.managedObjectContext.perform { [weak self, frc, request] in diff --git a/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift index 0943040..5e5926c 100644 --- a/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift @@ -10,7 +10,7 @@ import Foundation /// Subscription provider that sends updates when an aggregate fetch request changes @usableFromInline final class AggregateThrowingSubscription: ThrowingSubscription - where Value: Numeric + where Value: Numeric, Value: Sendable { @usableFromInline override func fetch() { diff --git a/Sources/CoreDataRepository/Internal/CountSubscription.swift b/Sources/CoreDataRepository/Internal/CountSubscription.swift index 28e9e22..6ff06da 100644 --- a/Sources/CoreDataRepository/Internal/CountSubscription.swift +++ b/Sources/CoreDataRepository/Internal/CountSubscription.swift @@ -9,7 +9,9 @@ import Foundation /// Subscription provider that sends updates when a count fetch request changes @usableFromInline -final class CountSubscription: Subscription where Value: Numeric { +final class CountSubscription: Subscription where Value: Numeric, + Value: Sendable +{ @usableFromInline override func fetch() { frc.managedObjectContext.perform { [weak self, frc] in diff --git a/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift index 01985bd..9394469 100644 --- a/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift @@ -10,7 +10,7 @@ import Foundation /// Subscription provider that sends updates when a count fetch request changes @usableFromInline final class CountThrowingSubscription: ThrowingSubscription - where Value: Numeric + where Value: Numeric, Value: Sendable { @usableFromInline override func fetch() { diff --git a/Sources/CoreDataRepository/Internal/Subscription.swift b/Sources/CoreDataRepository/Internal/Subscription.swift index 60d71a2..00450f7 100644 --- a/Sources/CoreDataRepository/Internal/Subscription.swift +++ b/Sources/CoreDataRepository/Internal/Subscription.swift @@ -13,7 +13,7 @@ class Subscription< Output, RequestResult: NSFetchRequestResult, ControllerResult: NSFetchRequestResult ->: BaseSubscription { +>: BaseSubscription where Output: Sendable { let continuation: AsyncStream>.Continuation @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift index 21ff393..cdf0e7a 100644 --- a/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift @@ -13,7 +13,7 @@ class ThrowingSubscription< Output, RequestResult: NSFetchRequestResult, ControllerResult: NSFetchRequestResult ->: BaseSubscription { +>: BaseSubscription where Output: Sendable { private let continuation: AsyncThrowingStream.Continuation @usableFromInline From 81193671697ca36892f081c1ad0852a4c3aea6fc Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:37:53 -0500 Subject: [PATCH 05/10] Mark all subscription types as unchecked sendable feature/sendable --- .../CoreDataRepository/Internal/AggregateSubscription.swift | 3 ++- .../Internal/AggregateThrowingSubscription.swift | 3 ++- Sources/CoreDataRepository/Internal/BaseSubscription.swift | 2 +- Sources/CoreDataRepository/Internal/CountSubscription.swift | 3 ++- .../Internal/CountThrowingSubscription.swift | 3 ++- Sources/CoreDataRepository/Internal/FetchSubscription.swift | 2 +- .../Internal/FetchThrowingSubscription.swift | 2 +- Sources/CoreDataRepository/Internal/ReadSubscription.swift | 2 +- .../CoreDataRepository/Internal/ReadThrowingSubscription.swift | 2 +- Sources/CoreDataRepository/Internal/Subscription.swift | 2 +- Sources/CoreDataRepository/Internal/ThrowingSubscription.swift | 2 +- 11 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Sources/CoreDataRepository/Internal/AggregateSubscription.swift b/Sources/CoreDataRepository/Internal/AggregateSubscription.swift index 3b14e8c..c70e275 100644 --- a/Sources/CoreDataRepository/Internal/AggregateSubscription.swift +++ b/Sources/CoreDataRepository/Internal/AggregateSubscription.swift @@ -9,7 +9,8 @@ import Foundation /// Subscription provider that sends updates when an aggregate fetch request changes @usableFromInline -final class AggregateSubscription: Subscription where Value: Numeric, +final class AggregateSubscription: Subscription, + @unchecked Sendable where Value: Numeric, Value: Sendable { @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift index 5e5926c..b273f35 100644 --- a/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/AggregateThrowingSubscription.swift @@ -9,7 +9,8 @@ import Foundation /// Subscription provider that sends updates when an aggregate fetch request changes @usableFromInline -final class AggregateThrowingSubscription: ThrowingSubscription +final class AggregateThrowingSubscription: ThrowingSubscription, + @unchecked Sendable where Value: Numeric, Value: Sendable { @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/BaseSubscription.swift b/Sources/CoreDataRepository/Internal/BaseSubscription.swift index 9abc8f9..822d9f0 100644 --- a/Sources/CoreDataRepository/Internal/BaseSubscription.swift +++ b/Sources/CoreDataRepository/Internal/BaseSubscription.swift @@ -13,7 +13,7 @@ class BaseSubscription< Output, RequestResult: NSFetchRequestResult, ControllerResult: NSFetchRequestResult ->: NSObject, NSFetchedResultsControllerDelegate { +>: NSObject, NSFetchedResultsControllerDelegate, @unchecked Sendable { let request: NSFetchRequest let frc: NSFetchedResultsController diff --git a/Sources/CoreDataRepository/Internal/CountSubscription.swift b/Sources/CoreDataRepository/Internal/CountSubscription.swift index 6ff06da..b7ca1e8 100644 --- a/Sources/CoreDataRepository/Internal/CountSubscription.swift +++ b/Sources/CoreDataRepository/Internal/CountSubscription.swift @@ -9,7 +9,8 @@ import Foundation /// Subscription provider that sends updates when a count fetch request changes @usableFromInline -final class CountSubscription: Subscription where Value: Numeric, +final class CountSubscription: Subscription, + @unchecked Sendable where Value: Numeric, Value: Sendable { @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift index 9394469..89fcbb2 100644 --- a/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/CountThrowingSubscription.swift @@ -9,7 +9,8 @@ import Foundation /// Subscription provider that sends updates when a count fetch request changes @usableFromInline -final class CountThrowingSubscription: ThrowingSubscription +final class CountThrowingSubscription: ThrowingSubscription, + @unchecked Sendable where Value: Numeric, Value: Sendable { @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/FetchSubscription.swift b/Sources/CoreDataRepository/Internal/FetchSubscription.swift index 121f520..127f8c7 100644 --- a/Sources/CoreDataRepository/Internal/FetchSubscription.swift +++ b/Sources/CoreDataRepository/Internal/FetchSubscription.swift @@ -13,7 +13,7 @@ final class FetchSubscription: Subscription< [Model], Model.ManagedModel, Model.ManagedModel -> { +>, @unchecked Sendable { @usableFromInline override func fetch() { frc.managedObjectContext.perform { [weak self, frc, request] in diff --git a/Sources/CoreDataRepository/Internal/FetchThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/FetchThrowingSubscription.swift index a86915e..8fbea57 100644 --- a/Sources/CoreDataRepository/Internal/FetchThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/FetchThrowingSubscription.swift @@ -13,7 +13,7 @@ final class FetchThrowingSubscription: ThrowingS [Model], Model.ManagedModel, Model.ManagedModel -> { +>, @unchecked Sendable { @usableFromInline override func fetch() { frc.managedObjectContext.perform { [weak self, frc, request] in diff --git a/Sources/CoreDataRepository/Internal/ReadSubscription.swift b/Sources/CoreDataRepository/Internal/ReadSubscription.swift index 6007ada..5c2a9e7 100644 --- a/Sources/CoreDataRepository/Internal/ReadSubscription.swift +++ b/Sources/CoreDataRepository/Internal/ReadSubscription.swift @@ -10,7 +10,7 @@ import Foundation /// Subscription provider that sends updates when a single ``NSManagedObject`` changes @usableFromInline -final class ReadSubscription { +final class ReadSubscription: @unchecked Sendable { private let objectId: NSManagedObjectID private let context: NSManagedObjectContext private var cancellables: Set diff --git a/Sources/CoreDataRepository/Internal/ReadThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/ReadThrowingSubscription.swift index 4f3d0f5..a8e6643 100644 --- a/Sources/CoreDataRepository/Internal/ReadThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/ReadThrowingSubscription.swift @@ -9,7 +9,7 @@ import CoreData import Foundation @usableFromInline -final class ReadThrowingSubscription { +final class ReadThrowingSubscription: @unchecked Sendable { private let objectId: NSManagedObjectID private let context: NSManagedObjectContext private var cancellables: Set diff --git a/Sources/CoreDataRepository/Internal/Subscription.swift b/Sources/CoreDataRepository/Internal/Subscription.swift index 00450f7..a6ac90d 100644 --- a/Sources/CoreDataRepository/Internal/Subscription.swift +++ b/Sources/CoreDataRepository/Internal/Subscription.swift @@ -13,7 +13,7 @@ class Subscription< Output, RequestResult: NSFetchRequestResult, ControllerResult: NSFetchRequestResult ->: BaseSubscription where Output: Sendable { +>: BaseSubscription, @unchecked Sendable where Output: Sendable { let continuation: AsyncStream>.Continuation @usableFromInline diff --git a/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift b/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift index cdf0e7a..b95fe86 100644 --- a/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift +++ b/Sources/CoreDataRepository/Internal/ThrowingSubscription.swift @@ -13,7 +13,7 @@ class ThrowingSubscription< Output, RequestResult: NSFetchRequestResult, ControllerResult: NSFetchRequestResult ->: BaseSubscription where Output: Sendable { +>: BaseSubscription, @unchecked Sendable where Output: Sendable { private let continuation: AsyncThrowingStream.Continuation @usableFromInline From 9a3bf9db74cd87871567637d578bd673316a7e13 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 15:41:14 -0500 Subject: [PATCH 06/10] Resolve or suppress more warnings feature/sendable --- .../Internal/ModelsWithIntId/IdentifiableModel_Int.swift | 2 +- Sources/Internal/ModelsWithIntId/ManagedModel_Int.swift | 2 +- .../Internal/ModelsWithUuidId/IdentifiableModel_Uuid.swift | 2 +- Sources/Internal/ModelsWithUuidId/ManagedModel_Uuid.swift | 2 +- Sources/Internal/NSManagedObjectModel+Constants.swift | 4 ++-- Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift | 6 +++--- Tests/CoreDataRepositoryTests/Read_BatchTests.swift | 1 - 7 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/Internal/ModelsWithIntId/IdentifiableModel_Int.swift b/Sources/Internal/ModelsWithIntId/IdentifiableModel_Int.swift index 6b9727f..9d7cc85 100644 --- a/Sources/Internal/ModelsWithIntId/IdentifiableModel_Int.swift +++ b/Sources/Internal/ModelsWithIntId/IdentifiableModel_Int.swift @@ -101,7 +101,7 @@ extension IdentifiableModel_IntId: IdentifiedUnmanagedModel { @inlinable package static var unmanagedIdAccessor: (IdentifiableModel_IntId) -> Int { \.id } - package static let managedIdExpression = NSExpression(forKeyPath: \ManagedModel_IntId.id) + package nonisolated(unsafe) static let managedIdExpression = NSExpression(forKeyPath: \ManagedModel_IntId.id) } extension IdentifiableModel_IntId: WritableUnmanagedModel { diff --git a/Sources/Internal/ModelsWithIntId/ManagedModel_Int.swift b/Sources/Internal/ModelsWithIntId/ManagedModel_Int.swift index e311471..3f06fa6 100644 --- a/Sources/Internal/ModelsWithIntId/ManagedModel_Int.swift +++ b/Sources/Internal/ModelsWithIntId/ManagedModel_Int.swift @@ -17,7 +17,7 @@ extension ManagedModel_IntId { entityDescription } - package static let entityDescription: NSEntityDescription = { + package nonisolated(unsafe) static let entityDescription: NSEntityDescription = { let desc = NSEntityDescription() desc.name = "ManagedModel_IntId" desc.managedObjectClassName = NSStringFromClass(ManagedModel_IntId.self) diff --git a/Sources/Internal/ModelsWithUuidId/IdentifiableModel_Uuid.swift b/Sources/Internal/ModelsWithUuidId/IdentifiableModel_Uuid.swift index ddc7252..853038a 100644 --- a/Sources/Internal/ModelsWithUuidId/IdentifiableModel_Uuid.swift +++ b/Sources/Internal/ModelsWithUuidId/IdentifiableModel_Uuid.swift @@ -101,7 +101,7 @@ extension IdentifiableModel_UuidId: IdentifiedUnmanagedModel { @inlinable package static var unmanagedIdAccessor: (IdentifiableModel_UuidId) -> UUID { \.id } - package static let managedIdExpression = NSExpression(forKeyPath: \ManagedModel_UuidId.id) + package nonisolated(unsafe) static let managedIdExpression = NSExpression(forKeyPath: \ManagedModel_UuidId.id) } extension IdentifiableModel_UuidId: WritableUnmanagedModel { diff --git a/Sources/Internal/ModelsWithUuidId/ManagedModel_Uuid.swift b/Sources/Internal/ModelsWithUuidId/ManagedModel_Uuid.swift index 35f6b7c..5dc3d19 100644 --- a/Sources/Internal/ModelsWithUuidId/ManagedModel_Uuid.swift +++ b/Sources/Internal/ModelsWithUuidId/ManagedModel_Uuid.swift @@ -17,7 +17,7 @@ extension ManagedModel_UuidId { entityDescription } - package static let entityDescription: NSEntityDescription = { + package nonisolated(unsafe) static let entityDescription: NSEntityDescription = { let desc = NSEntityDescription() desc.name = "ManagedModel_UuidId" desc.managedObjectClassName = NSStringFromClass(ManagedModel_UuidId.self) diff --git a/Sources/Internal/NSManagedObjectModel+Constants.swift b/Sources/Internal/NSManagedObjectModel+Constants.swift index ac33394..878d06c 100644 --- a/Sources/Internal/NSManagedObjectModel+Constants.swift +++ b/Sources/Internal/NSManagedObjectModel+Constants.swift @@ -7,13 +7,13 @@ import CoreData extension NSManagedObjectModel { - package static let model_UuidId: NSManagedObjectModel = { + package nonisolated(unsafe) static let model_UuidId: NSManagedObjectModel = { let model = NSManagedObjectModel() model.entities = [ManagedModel_UuidId.entity()] return model }() - package static let model_IntId: NSManagedObjectModel = { + package nonisolated(unsafe) static let model_IntId: NSManagedObjectModel = { let model = NSManagedObjectModel() model.entities = [ManagedModel_IntId.entity()] return model diff --git a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift index 978dcc1..b4fa651 100644 --- a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift +++ b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift @@ -59,9 +59,9 @@ class CoreDataXCTestCase: XCTestCase { func verify(_ item: T) async throws where T: FetchableUnmanagedModel, T: Equatable { let context = try repositoryContext() context.performAndWait { - var _managed: T.ManagedModel? + var managed: T.ManagedModel? do { - _managed = try context.fetch(T.managedFetchRequest()).first { try T(managed: $0) == item } + managed = try context.fetch(T.managedFetchRequest()).first { try T(managed: $0) == item } } catch { XCTFail( "Failed to verify item in store because fetching failed. Error: \(error.localizedDescription)" @@ -69,7 +69,7 @@ class CoreDataXCTestCase: XCTestCase { return } - guard let managed = _managed else { + guard managed != nil else { XCTFail("Failed to verify item in store because it was not found.") return } diff --git a/Tests/CoreDataRepositoryTests/Read_BatchTests.swift b/Tests/CoreDataRepositoryTests/Read_BatchTests.swift index 1e8057a..35cadf5 100644 --- a/Tests/CoreDataRepositoryTests/Read_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Read_BatchTests.swift @@ -632,7 +632,6 @@ final class Read_BatchTests: CoreDataXCTestCase { func testReadAtomically_ManagedIdUrl_Success() async throws { let modelType = ManagedIdUrlModel_UuidId.self - let transactionAuthor: String = #function let _values = [ modelType.seeded(1), modelType.seeded(2), From 1a36b00af47bbd764e12406ef8e924a8732fdeb2 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 16:11:11 -0500 Subject: [PATCH 07/10] Create permanent test plan with CoreData arguments included feature/sendable --- CoreDataRepository.xctestplan | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 CoreDataRepository.xctestplan diff --git a/CoreDataRepository.xctestplan b/CoreDataRepository.xctestplan new file mode 100644 index 0000000..f4620e6 --- /dev/null +++ b/CoreDataRepository.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "3473C2E6-8765-4CE5-8DCC-FAF7D66E9FD2", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "commandLineArgumentEntries" : [ + { + "argument" : "-com.apple.CoreData.ConcurrencyDebug 1" + }, + { + "argument" : "-com.apple.CoreData.SQLDebug 4", + "enabled" : false + }, + { + "argument" : "-com.apple.CoreData.SQLDebug 4", + "enabled" : false + } + ] + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "CoreDataRepositoryTests", + "name" : "CoreDataRepositoryTests" + } + } + ], + "version" : 1 +} From 2c0def0c52fcb944acdae9b56dee1ae10f1e28f4 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 16:11:32 -0500 Subject: [PATCH 08/10] Fix CoreData concurrency violations in tests feature/sendable --- .../CoreDataXCTestCase.swift | 14 ++++++++++++++ .../Create_BatchTests.swift | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift index b4fa651..a7ecc8d 100644 --- a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift +++ b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift @@ -10,6 +10,14 @@ import CustomDump import Internal import XCTest +extension FetchableUnmanagedModel { + static func initFromManaged(_ managed: ManagedModel) throws -> Self { + try managed.managedObjectContext!.performAndWait { + try Self.init(managed: managed) + } + } +} + class CoreDataXCTestCase: XCTestCase { var _container: NSPersistentContainer? var _repositoryContext: NSManagedObjectContext? @@ -55,6 +63,12 @@ class CoreDataXCTestCase: XCTestCase { _repositoryContext = nil _repository = nil } + + func mapInContext(_ input: I, transform: (I) throws -> O) throws -> O { + try repositoryContext().performAndWait { + try transform(input) + } + } func verify(_ item: T) async throws where T: FetchableUnmanagedModel, T: Equatable { let context = try repositoryContext() diff --git a/Tests/CoreDataRepositoryTests/Create_BatchTests.swift b/Tests/CoreDataRepositoryTests/Create_BatchTests.swift index 05bf90b..13e7806 100644 --- a/Tests/CoreDataRepositoryTests/Create_BatchTests.swift +++ b/Tests/CoreDataRepositoryTests/Create_BatchTests.swift @@ -431,7 +431,7 @@ final class Create_BatchTests: CoreDataXCTestCase { try self.repositoryContext().parent?.save() return value } - try await verify(modelType.init(managed: existingValue)) + try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) let result = try await repository() .createAtomically(_values) @@ -514,7 +514,7 @@ final class Create_BatchTests: CoreDataXCTestCase { try self.repositoryContext().parent?.save() return value } - try await verify(modelType.init(managed: existingValue)) + try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) let result = try await repository() .createAtomically(_values) @@ -597,7 +597,7 @@ final class Create_BatchTests: CoreDataXCTestCase { try self.repositoryContext().parent?.save() return value } - try await verify(modelType.init(managed: existingValue)) + try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) let result = try await repository() .createAtomically(_values) @@ -680,7 +680,7 @@ final class Create_BatchTests: CoreDataXCTestCase { try self.repositoryContext().parent?.save() return value } - try await verify(modelType.init(managed: existingValue)) + try await verify(mapInContext(existingValue, transform: modelType.init(managed:))) let result = try await repository() .createAtomically(_values) From a83d9a79c31b6374171e29bce0f930757f149e4f Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 2 May 2025 16:11:48 -0500 Subject: [PATCH 09/10] Run swiftformat feature/sendable --- Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift index a7ecc8d..086ee8c 100644 --- a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift +++ b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift @@ -13,7 +13,7 @@ import XCTest extension FetchableUnmanagedModel { static func initFromManaged(_ managed: ManagedModel) throws -> Self { try managed.managedObjectContext!.performAndWait { - try Self.init(managed: managed) + try Self(managed: managed) } } } @@ -63,7 +63,7 @@ class CoreDataXCTestCase: XCTestCase { _repositoryContext = nil _repository = nil } - + func mapInContext(_ input: I, transform: (I) throws -> O) throws -> O { try repositoryContext().performAndWait { try transform(input) From f479476f999582ac9ef98ead6d8364808bc8e3c0 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Sun, 4 May 2025 10:50:50 -0500 Subject: [PATCH 10/10] Remove temporary code feature/sendable --- Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift index 086ee8c..8a0458b 100644 --- a/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift +++ b/Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift @@ -10,14 +10,6 @@ import CustomDump import Internal import XCTest -extension FetchableUnmanagedModel { - static func initFromManaged(_ managed: ManagedModel) throws -> Self { - try managed.managedObjectContext!.performAndWait { - try Self(managed: managed) - } - } -} - class CoreDataXCTestCase: XCTestCase { var _container: NSPersistentContainer? var _repositoryContext: NSManagedObjectContext?