diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb0e9e4..a1c637e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,9 @@ jobs: platform: - iOS - tvOS - #- watchOS + - watchOS - macOS - runs-on: macos-latest + runs-on: macos-12 steps: - name: Checkout uses: actions/checkout@v2 @@ -34,13 +34,15 @@ jobs: #uses: codecov/codecov-action@v2 run: bash <(curl -s https://codecov.io/bash); validate: - runs-on: macos-latest + runs-on: macos-12 needs: build steps: - name: Checkout uses: actions/checkout@v2 - name: Swift Lint run: swiftlint --strict + - name: Swift Format + run: swiftformat --lint . - name: Pod Lint run: pod lib lint --quick --fail-fast --verbose --skip-tests - name: Example Project diff --git a/.swiftformat b/.swiftformat index d5e27f7..727bdd6 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,16 +1,15 @@ ---swiftversion 5.5 +--swiftversion 5.7 --exclude Pods +--rules andOperator --rules anyObjectProtocol +--rules blankLineAfterImports --rules braces - --rules duplicateImports --rules elseOnSameLine - ---rules strongifiedSelf ---rules andOperator +--rules genericExtensions --rules indent --indent 4 @@ -21,24 +20,28 @@ --rules leadingDelimiters --rules linebreakAtEndOfFile +--rules redundantFileprivate --rules redundantGet --rules redundantInit +--rules redundantOptionalBinding --rules redundantParens --rules redundantVoidReturnType --rules semicolons + --rules spaceAroundBraces --rules spaceAroundBrackets --rules spaceAroundGenerics - --rules spaceAroundOperators --rules spaceAroundParens + --rules spaceInsideBraces --rules spaceInsideBrackets --rules spaceInsideComments --rules spaceInsideGenerics --rules spaceInsideParens ---rules todos +--rules strongifiedSelf +--rules todos --rules trailingCommas --rules trailingSpace --rules typeSugar diff --git a/.swiftlint.yml b/.swiftlint.yml index 1218296..5daba99 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -54,6 +54,7 @@ opt_in_rules: - reduce_into - redundant_nil_coalescing - redundant_objc_attribute + - self_binding - sorted_first_last - static_operator - strong_iboutlet @@ -69,6 +70,7 @@ opt_in_rules: - xct_specific_matcher excluded: - Pods + - .build type_name: excluded: @@ -92,7 +94,7 @@ large_tuple: warning: 3 error: 4 deployment_target: - iOS_deployment_target: 10 - tvOS_deployment_target: 10 - watchOS_deployment_target: 3 - macOS_deployment_target: 10.13 + iOS_deployment_target: 13 + tvOS_deployment_target: 13 + watchOS_deployment_target: 6 + macOS_deployment_target: 10.15 diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ffac5..a35df9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. `FetchRequests` adheres to [Semantic Versioning](https://semver.org/). +## [5.0](https://github.com/square/FetchRequests/releases/tag/5.0.0) +Released on 2022-10-25 + +* Requires Swift 5.7 +* Protocols define their primary associated types +* JSON literal arrays and dictionaries now must be strongly typed via the `JSONConvertible` protocol +* Annotate many methods as @MainActor + * All delegate methods + * All code with assert(Thread.isMainThread) +* Faulting an association when you're off the main thread will have different characteristics + * If the association already exists, nothing will change + * If the association does not already exist, it will always return nil and hit the main thread to batch fetch the associations +* More eventing supports occurring off of the main thread + * If needed, it will async bounce to the main thread to actually perform the change + * Newly allowed Events: + * Associated Value creation events + * Entity creation events + * Data reset events + * Note any changes to your model still must occur on the main thread + * data + * isDeleted + * NSSortDescriptor keyPaths + * Association keyPaths + ## [4.0.4](https://github.com/square/FetchRequests/releases/tag/4.0.4) Released on 2022-08-30 diff --git a/Example/iOS-Example/Model+Persistence.swift b/Example/iOS-Example/Model+Persistence.swift index f0092ec..bbc233a 100644 --- a/Example/iOS-Example/Model+Persistence.swift +++ b/Example/iOS-Example/Model+Persistence.swift @@ -47,7 +47,7 @@ extension Model { return UserDefaults.standard.dictionary(forKey: key) ?? [:] } - fileprivate class func updateStorage(_ block: (inout [String: Any]) throws -> Void) rethrows { + private class func updateStorage(_ block: (inout [String: Any]) throws -> Void) rethrows { assert(Thread.isMainThread) let defaults = UserDefaults.standard diff --git a/Example/iOS-Example/Model.swift b/Example/iOS-Example/Model.swift index bf617b8..618d5f8 100644 --- a/Example/iOS-Example/Model.swift +++ b/Example/iOS-Example/Model.swift @@ -109,12 +109,14 @@ extension Model { // MARK: - FetchableObjectProtocol extension Model: FetchableObjectProtocol { - func observeDataChanges(_ handler: @escaping () -> Void) -> InvalidatableToken { - return _data.observeChanges { change in handler() } + func observeDataChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { + return _data.observeChanges { change in + handler() + } } - func observeIsDeletedChanges(_ handler: @escaping () -> Void) -> InvalidatableToken { - return self.observe(\.isDeleted, options: [.old, .new]) { object, change in + func observeIsDeletedChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { + return self.observe(\.isDeleted, options: [.old, .new]) { @MainActor(unsafe) object, change in guard let old = change.oldValue, let new = change.newValue, old != new else { return } diff --git a/Example/iOS-Example/Observable.swift b/Example/iOS-Example/Observable.swift index 866502c..714fe7a 100644 --- a/Example/iOS-Example/Observable.swift +++ b/Example/iOS-Example/Observable.swift @@ -17,9 +17,12 @@ struct Change { @propertyWrapper class Observable { - fileprivate var observers: Atomic<[UUID: (Change) -> Void]> = Atomic(wrappedValue: [:]) + typealias Handler = @MainActor (Change) -> Void + + fileprivate var observers: Atomic<[UUID: Handler]> = Atomic(wrappedValue: [:]) var wrappedValue: Value { + @MainActor(unsafe) didSet { assert(Thread.isMainThread) @@ -34,7 +37,7 @@ class Observable { self.wrappedValue = wrappedValue } - func observe(handler: @escaping (Change) -> Void) -> InvalidatableToken { + func observe(handler: @escaping Handler) -> InvalidatableToken { let token = Token(parent: self) observers.mutate { value in value[token.uuid] = handler @@ -44,7 +47,7 @@ class Observable { } extension Observable where Value: Equatable { - func observeChanges(handler: @escaping (Change) -> Void) -> InvalidatableToken { + func observeChanges(handler: @escaping Handler) -> InvalidatableToken { return observe { change in guard change.oldValue != change.newValue else { return diff --git a/Example/iOS-Example/ViewController.swift b/Example/iOS-Example/ViewController.swift index dab1502..a7b26e0 100644 --- a/Example/iOS-Example/ViewController.swift +++ b/Example/iOS-Example/ViewController.swift @@ -105,7 +105,7 @@ extension ViewController { style: .destructive, title: NSLocalizedString("Delete", comment: "Delete") ) { [weak self] action, view, completion in - guard let self = self else { + guard let self else { return } let model = self.controller.object(at: indexPath) diff --git a/FetchRequests.podspec b/FetchRequests.podspec index f078db8..5155288 100644 --- a/FetchRequests.podspec +++ b/FetchRequests.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FetchRequests' - s.version = '4.0.4' + s.version = '5.0.0' s.license = 'MIT' s.summary = 'NSFetchedResultsController inspired eventing' s.homepage = 'https://github.com/square/FetchRequests' @@ -9,13 +9,13 @@ Pod::Spec.new do |s| ios_deployment_target = '13.0' tvos_deployment_target = '13.0' - macos_deployment_target = '10.15' watchos_deployment_target = '6.0' + macos_deployment_target = '10.15' s.ios.deployment_target = ios_deployment_target s.tvos.deployment_target = tvos_deployment_target - s.macos.deployment_target = macos_deployment_target s.watchos.deployment_target = watchos_deployment_target + s.macos.deployment_target = macos_deployment_target s.swift_version = '5.0' @@ -28,8 +28,8 @@ Pod::Spec.new do |s| test_spec.source_files = 'FetchRequests/Tests/**/*.swift' test_spec.ios.deployment_target = ios_deployment_target - test_spec.watchos.deployment_target = watchos_deployment_target test_spec.tvos.deployment_target = tvos_deployment_target + test_spec.watchos.deployment_target = watchos_deployment_target test_spec.macos.deployment_target = macos_deployment_target end diff --git a/FetchRequests.xcodeproj/project.pbxproj b/FetchRequests.xcodeproj/project.pbxproj index 5aaa01c..f5e18b2 100644 --- a/FetchRequests.xcodeproj/project.pbxproj +++ b/FetchRequests.xcodeproj/project.pbxproj @@ -488,7 +488,6 @@ isa = PBXNativeTarget; buildConfigurationList = 471C508022C6D0DC007F73E9 /* Build configuration list for PBXNativeTarget "FetchRequests-iOS" */; buildPhases = ( - 4736ABDC22CC3A1500253EB6 /* SwiftLint */, 471C506722C6D0DB007F73E9 /* Headers */, 471C506822C6D0DB007F73E9 /* Sources */, 471C506922C6D0DB007F73E9 /* Frameworks */, @@ -649,7 +648,7 @@ attributes = { CLASSPREFIX = ""; LastSwiftUpdateCheck = 1100; - LastUpgradeCheck = 1100; + LastUpgradeCheck = 1400; ORGANIZATIONNAME = "Speramus Inc."; TargetAttributes = { 471C506B22C6D0DB007F73E9 = { @@ -747,27 +746,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 4736ABDC22CC3A1500253EB6 /* SwiftLint */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = SwiftLint; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "PATH=$PATH:/usr/local/bin:/opt/homebrew/bin:/opt/brew/bin\n\nif which swiftlint >/dev/null; then\n swiftlint --strict\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 471C506822C6D0DB007F73E9 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -1039,7 +1017,7 @@ INFOPLIST_FILE = FetchRequests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0; + MARKETING_VERSION = 5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1049,6 +1027,7 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = ""; @@ -1113,7 +1092,7 @@ INFOPLIST_FILE = FetchRequests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0; + MARKETING_VERSION = 5.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.crewapp.FetchRequests; @@ -1122,6 +1101,7 @@ SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = ""; @@ -1290,6 +1270,7 @@ 47A6ED4422CA90A20034A854 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + DEAD_CODE_STRIPPING = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -1304,6 +1285,7 @@ 47A6ED4522CA90A20034A854 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + DEAD_CODE_STRIPPING = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -1319,6 +1301,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = FetchRequests/TestsInfo.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1335,6 +1318,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = FetchRequests/TestsInfo.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8cef17f..45f2ba8 100644 --- a/FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,16 +1,14 @@ { - "object": { - "pins": [ - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections.git", - "state": { - "branch": null, - "revision": "2d33a0ea89c961dcb2b3da2157963d9c0370347e", - "version": "1.0.1" - } + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", + "version" : "1.0.3" } - ] - }, - "version": 1 + } + ], + "version" : 2 } diff --git a/FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-iOS.xcscheme b/FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-iOS.xcscheme index acac473..3ffc796 100644 --- a/FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-iOS.xcscheme +++ b/FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-iOS.xcscheme @@ -1,6 +1,6 @@ : AssociatedValu } let isDeletedObserver = entity.observeIsDeletedChanges { [weak self, weak entity] in - guard let entity = entity else { + guard let entity else { return } self?.observedDeletionEvent(with: entity) @@ -55,6 +55,7 @@ class FetchableAssociatedValueReference: AssociatedValu return [dataObserver, isDeletedObserver] } + @MainActor private func observedDeletionEvent(with entity: Entity) { var invalidate = false if let value = value as? Entity, value == entity { @@ -71,11 +72,14 @@ class FetchableAssociatedValueReference: AssociatedValu } class AssociatedValueReference: NSObject { + typealias CreationObserved = @MainActor (_ value: Any?, _ entity: Any) -> AssociationReplacement + typealias ChangeHandler = @MainActor (_ invalidate: Bool) -> Void + private let creationObserver: FetchRequestObservableToken? - private let creationObserved: (Any?, Any) -> AssociationReplacement + private let creationObserved: CreationObserved fileprivate(set) var value: Any? - fileprivate var changeHandler: ((_ invalidate: Bool) -> Void)? + fileprivate var changeHandler: ChangeHandler? var canObserveCreation: Bool { return creationObserver != nil @@ -83,7 +87,7 @@ class AssociatedValueReference: NSObject { init( creationObserver: FetchRequestObservableToken? = nil, - creationObserved: @escaping (Any?, Any) -> AssociationReplacement = { _, _ in .same }, + creationObserved: @escaping CreationObserved = { _, _ in .same }, value: Any? = nil ) { self.creationObserver = creationObserver @@ -107,7 +111,7 @@ extension AssociatedValueReference { self.value = value } - func observeChanges(_ changeHandler: @escaping (_ invalidate: Bool) -> Void) { + func observeChanges(_ changeHandler: @escaping ChangeHandler) { stopObserving() self.changeHandler = changeHandler @@ -115,8 +119,9 @@ extension AssociatedValueReference { startObservingValue() creationObserver?.observeIfNeeded { [weak self] entity in - assert(Thread.isMainThread) - self?.observedCreationEvent(with: entity) + performOnMainThread { + self?.observedCreationEvent(with: entity) + } } } @@ -132,7 +137,10 @@ extension AssociatedValueReference { changeHandler = nil } + @MainActor private func observedCreationEvent(with entity: Any) { + assert(Thread.isMainThread) + // We just received a notification about an entity being created switch creationObserved(value, entity) { @@ -147,7 +155,7 @@ extension AssociatedValueReference { stopObservingAndUpdateValue(to: newValue) - if let currentChangeHandler = currentChangeHandler { + if let currentChangeHandler { observeChanges(currentChangeHandler) currentChangeHandler(false) } diff --git a/FetchRequests/Sources/Associations/FetchRequestAssociation.swift b/FetchRequests/Sources/Associations/FetchRequestAssociation.swift index c9b752e..8e57531 100644 --- a/FetchRequests/Sources/Associations/FetchRequestAssociation.swift +++ b/FetchRequests/Sources/Associations/FetchRequestAssociation.swift @@ -17,9 +17,9 @@ public enum AssociationReplacement { /// Map an associated value's key to object public class FetchRequestAssociation { /// Fetch associated values given a list of parent objects - public typealias AssocationRequestByParent = (_ objects: [FetchedObject], _ completion: @escaping ([FetchedObject.ID: AssociatedEntity]) -> Void) -> Void + public typealias AssocationRequestByParent = @MainActor (_ objects: [FetchedObject], _ completion: @escaping ([FetchedObject.ID: AssociatedEntity]) -> Void) -> Void /// Fetch associated values given a list of associated IDs - public typealias AssocationRequestByID = (_ objects: [AssociatedEntityID], _ completion: @escaping ([AssociatedEntity]) -> Void) -> Void + public typealias AssocationRequestByID = @MainActor (_ objects: [AssociatedEntityID], _ completion: @escaping ([AssociatedEntity]) -> Void) -> Void /// Event that represents the creation of an associated value object public typealias CreationObserved = (Value?, Comparison) -> AssociationReplacement /// Start observing a source object @@ -60,7 +60,7 @@ public class FetchRequestAssociation { ) { let wrappedObserveKeyPath: KeyPathObservation = { object, changeHandler in observeKeyPath(object) { object, oldValue, newValue in - guard let oldValue = oldValue, let newValue = newValue else { + guard let oldValue, let newValue else { return } changeHandler(object, oldValue, newValue) @@ -136,13 +136,13 @@ public extension FetchRequestAssociation { AssociatedEntity: FetchableObject, RawAssociatedEntity, AssociatedEntityID: Equatable, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, request: @escaping AssocationRequestByParent, creationTokenGenerator: @escaping TokenGenerator, creationObserved: @escaping CreationObserved - ) where Token.Parameter == RawAssociatedEntity { + ) { let creationTokenGenerator: TokenGenerator> = { parentObject in let token = creationTokenGenerator(parentObject) return token.map { FetchRequestObservableToken(typeErasedToken: $0) } @@ -199,13 +199,13 @@ public extension FetchRequestAssociation { AssociatedEntity: FetchableObject, RawAssociatedEntity, AssociatedEntityID: Equatable, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, request: @escaping AssocationRequestByParent, creationTokenGenerator: @escaping TokenGenerator, creationObserved: @escaping CreationObserved - ) where Token.Parameter == RawAssociatedEntity { + ) { let creationTokenGenerator: TokenGenerator> = { parentObject in let token = creationTokenGenerator(parentObject) return token.map { FetchRequestObservableToken(typeErasedToken: $0) } @@ -261,13 +261,13 @@ public extension FetchRequestAssociation { convenience init< AssociatedEntity: FetchableObject, AssociatedEntityID: Equatable, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, request: @escaping AssocationRequestByParent, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == AssociatedEntity { + ) { self.init( keyPath: keyPath, request: request, @@ -285,13 +285,13 @@ public extension FetchRequestAssociation { convenience init< AssociatedEntity: FetchableObject, AssociatedEntityID: Equatable, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, request: @escaping AssocationRequestByParent, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == AssociatedEntity { + ) { self.init( keyPath: keyPath, request: request, @@ -312,14 +312,14 @@ public extension FetchRequestAssociation { /// Association by non-optional entity ID whose creation event can also be observed convenience init< AssociatedEntity: FetchableObject, - Token: ObservableToken + Token: ObservableToken >( for associatedType: AssociatedEntity.Type, keyPath: KeyPath, request: @escaping AssocationRequestByID, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == AssociatedEntity.RawData { + ) { let rawRequest: AssocationRequestByParent = { objects, completion in var valuesSet: Set = [] var valuesOrdered: [AssociatedEntity.ID] = [] @@ -400,14 +400,14 @@ public extension FetchRequestAssociation { /// Association by optional entity ID whose creation event can also be observed convenience init< AssociatedEntity: FetchableObject, - Token: ObservableToken + Token: ObservableToken >( for associatedType: AssociatedEntity.Type, keyPath: KeyPath, request: @escaping AssocationRequestByID, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == AssociatedEntity.RawData { + ) { let rawRequest: AssocationRequestByParent = { objects, completion in var valuesSet: Set = [] var valuesOrdered: [AssociatedEntity.ID] = [] @@ -496,14 +496,14 @@ public extension FetchRequestAssociation { /// Array association by non-optional entity IDs whose creation event can also be observed convenience init< AssociatedEntity: FetchableObject, - Token: ObservableToken + Token: ObservableToken >( for associatedType: [AssociatedEntity].Type, keyPath: KeyPath, request: @escaping AssocationRequestByID, creationTokenGenerator: @escaping TokenGenerator<[AssociatedEntity.ID], Token>, creationObserved: @escaping CreationObserved<[AssociatedEntity], AssociatedEntity.RawData> - ) where Token.Parameter == AssociatedEntity.RawData { + ) { self.init( for: associatedType, keyPath: keyPath, @@ -518,7 +518,7 @@ public extension FetchRequestAssociation { convenience init< AssociatedEntity: FetchableObject, Reference: Hashable, - Token: ObservableToken + Token: ObservableToken >( for associatedType: [AssociatedEntity].Type, keyPath: KeyPath, @@ -526,7 +526,7 @@ public extension FetchRequestAssociation { referenceAccessor: @escaping (AssociatedEntity) -> Reference, creationTokenGenerator: @escaping TokenGenerator<[Reference], Token>, creationObserved: @escaping CreationObserved<[AssociatedEntity], AssociatedEntity.RawData> - ) where Token.Parameter == AssociatedEntity.RawData { + ) { let rawRequest: AssocationRequestByParent = { objects, completion in var valuesSet: Set = [] var valuesOrdered: [Reference] = [] @@ -620,14 +620,14 @@ public extension FetchRequestAssociation { /// Array association by optional entity IDs whose creation event can also be observed convenience init< AssociatedEntity: FetchableObject, - Token: ObservableToken + Token: ObservableToken >( for associatedType: [AssociatedEntity].Type, keyPath: KeyPath, request: @escaping AssocationRequestByID, creationTokenGenerator: @escaping TokenGenerator<[AssociatedEntity.ID], Token>, creationObserved: @escaping CreationObserved<[AssociatedEntity], AssociatedEntity.RawData> - ) where Token.Parameter == AssociatedEntity.RawData { + ) { self.init( for: associatedType, keyPath: keyPath, @@ -642,7 +642,7 @@ public extension FetchRequestAssociation { convenience init< AssociatedEntity: FetchableObject, Reference: Hashable, - Token: ObservableToken + Token: ObservableToken >( for associatedType: [AssociatedEntity].Type, keyPath: KeyPath, @@ -650,7 +650,7 @@ public extension FetchRequestAssociation { referenceAccessor: @escaping (AssociatedEntity) -> Reference, creationTokenGenerator: @escaping TokenGenerator<[Reference], Token>, creationObserved: @escaping CreationObserved<[AssociatedEntity], AssociatedEntity.RawData> - ) where Token.Parameter == AssociatedEntity.RawData { + ) { let rawRequest: AssocationRequestByParent = { objects, completion in var valuesSet: Set = [] var valuesOrdered: [Reference] = [] @@ -748,12 +748,12 @@ public extension FetchRequestAssociation { /// Association by non-optional entity ID whose creation event can also be observed convenience init< EntityID: FetchableEntityID, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == EntityID.FetchableEntity.RawData { + ) { typealias AssociatedType = EntityID.FetchableEntity var valuesSet: Set = [] @@ -820,12 +820,12 @@ public extension FetchRequestAssociation { /// Association by optional entity ID whose creation event can also be observed convenience init< EntityID: FetchableEntityID, - Token: ObservableToken + Token: ObservableToken >( keyPath: KeyPath, creationTokenGenerator: @escaping TokenGenerator, preferExistingValueOnCreate: Bool - ) where Token.Parameter == EntityID.FetchableEntity.RawData { + ) { typealias AssociatedType = EntityID.FetchableEntity var valuesSet: Set = [] @@ -905,8 +905,8 @@ private extension Sequence { } } -private extension Sequence where Iterator.Element: FetchableObjectProtocol { - func createLookupTable() -> [Iterator.Element.ID: Iterator.Element] { +private extension Sequence where Element: FetchableObjectProtocol { + func createLookupTable() -> [Element.ID: Element] { return self.associated(by: \.id) } } diff --git a/FetchRequests/Sources/Associations/FetchableEntityID.swift b/FetchRequests/Sources/Associations/FetchableEntityID.swift index ab8f8b7..ceeae36 100644 --- a/FetchRequests/Sources/Associations/FetchableEntityID.swift +++ b/FetchRequests/Sources/Associations/FetchableEntityID.swift @@ -8,7 +8,7 @@ import Foundation -public protocol FetchableEntityID: Hashable { +public protocol FetchableEntityID: Hashable { associatedtype FetchableEntity: FetchableObject init?(from entity: FetchableEntity) diff --git a/FetchRequests/Sources/Associations/ObservableToken.swift b/FetchRequests/Sources/Associations/ObservableToken.swift index ba02359..7ee583e 100644 --- a/FetchRequests/Sources/Associations/ObservableToken.swift +++ b/FetchRequests/Sources/Associations/ObservableToken.swift @@ -21,7 +21,7 @@ public protocol InvalidatableToken: AnyObject { func invalidate() } -public protocol ObservableToken: InvalidatableToken { +public protocol ObservableToken: InvalidatableToken { associatedtype Parameter func observe(handler: @escaping (Parameter) -> Void) @@ -75,11 +75,26 @@ internal class LegacyKeyValueObserving: NSObject, private var unsafeIsObserving = true - convenience init(object: Object, keyPath: AnyKeyPath, type: Value.Type, handler: @escaping Handler) { - self.init(object: object, keyPath: keyPath._kvcKeyPathString!, type: type, handler: handler) + convenience init( + object: Object, + keyPath: AnyKeyPath, + type: Value.Type, + handler: @escaping Handler + ) { + self.init( + object: object, + keyPath: keyPath._kvcKeyPathString!, + type: type, + handler: handler + ) } - init(object: Object, keyPath: String, type: Value.Type, handler: @escaping Handler) { + init( + object: Object, + keyPath: String, + type: Value.Type, + handler: @escaping Handler + ) { self.object = object self.keyPath = keyPath self.handler = handler @@ -107,9 +122,22 @@ internal class LegacyKeyValueObserving: NSObject, } // swiftlint:disable:next block_based_kvo - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - guard let typedObject = object as? Object, typedObject == self.object, keyPath == self.keyPath else { - return super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + guard let typedObject = object as? Object, + typedObject == self.object, + keyPath == self.keyPath + else { + return super.observeValue( + forKeyPath: keyPath, + of: object, + change: change, + context: context + ) } let oldValue = change?[.oldKey] as? Value @@ -120,7 +148,9 @@ internal class LegacyKeyValueObserving: NSObject, } internal class FetchRequestObservableToken: ObservableToken { - private let _observe: (_ handler: @escaping (Parameter) -> Void) -> Void + typealias Handler = (Parameter) -> Void + + private let _observe: (_ handler: @escaping Handler) -> Void private let _invalidate: () -> Void var isObserving: Bool { @@ -131,17 +161,20 @@ internal class FetchRequestObservableToken: ObservableToken { private var unsafeIsObserving = false - private init(observe: @escaping (_ handler: @escaping (Parameter) -> Void) -> Void, invalidate: @escaping () -> Void) { + private init( + observe: @escaping (_ handler: @escaping Handler) -> Void, + invalidate: @escaping () -> Void + ) { _observe = observe _invalidate = invalidate } - init(token: Token) where Token.Parameter == Parameter { + init(token: some ObservableToken) { _observe = { token.observe(handler: $0) } _invalidate = { token.invalidate() } } - func observeIfNeeded(handler: @escaping (Parameter) -> Void) { + func observeIfNeeded(handler: @escaping Handler) { synchronized(self) { guard !unsafeIsObserving else { return @@ -154,7 +187,7 @@ internal class FetchRequestObservableToken: ObservableToken { } } - func observe(handler: @escaping (Parameter) -> Void) { + func observe(handler: @escaping Handler) { synchronized(self) { _observe(handler) } diff --git a/FetchRequests/Sources/BoxedJSON.swift b/FetchRequests/Sources/BoxedJSON.swift index f670a9d..8f3217b 100644 --- a/FetchRequests/Sources/BoxedJSON.swift +++ b/FetchRequests/Sources/BoxedJSON.swift @@ -22,7 +22,7 @@ public class BoxedJSON: NSObject, NSSecureCoding { @objc(initWithObject:) public convenience init?(__object object: NSObject?) { - guard let object = object, let json = JSON(object) else { + guard let object, let json = JSON(object) else { return nil } self.init(json) diff --git a/FetchRequests/Sources/CollectionType+SortDescriptors.swift b/FetchRequests/Sources/CollectionType+SortDescriptors.swift index c65a7a3..fa0bc19 100644 --- a/FetchRequests/Sources/CollectionType+SortDescriptors.swift +++ b/FetchRequests/Sources/CollectionType+SortDescriptors.swift @@ -8,7 +8,7 @@ import Foundation -public extension Sequence where Iterator.Element: NSSortDescriptor { +public extension Sequence where Element: NSSortDescriptor { var comparator: Comparator { return { lhs, rhs in for sort in self { @@ -22,8 +22,8 @@ public extension Sequence where Iterator.Element: NSSortDescriptor { } } -public extension Sequence where Iterator.Element: NSObject { - func sorted(by descriptors: [NSSortDescriptor]) -> [Iterator.Element] { +public extension Sequence where Element: NSObject { + func sorted(by descriptors: [NSSortDescriptor]) -> [Element] { guard !descriptors.isEmpty else { return Array(self) } @@ -31,7 +31,7 @@ public extension Sequence where Iterator.Element: NSObject { return sorted(by: descriptors.comparator) } - private func sorted(by comparator: Comparator) -> [Iterator.Element] { + private func sorted(by comparator: Comparator) -> [Element] { return sorted { comparator($0, $1) == .orderedAscending } } } diff --git a/FetchRequests/Sources/Controller/CollapsibleSectionsFetchedResultsController.swift b/FetchRequests/Sources/Controller/CollapsibleSectionsFetchedResultsController.swift index 1f6b290..ed3feb6 100644 --- a/FetchRequests/Sources/Controller/CollapsibleSectionsFetchedResultsController.swift +++ b/FetchRequests/Sources/Controller/CollapsibleSectionsFetchedResultsController.swift @@ -18,7 +18,8 @@ public struct SectionCollapseConfig: Equatable { } } -public protocol CollapsibleSectionsFetchedResultsControllerDelegate: AnyObject { +@MainActor +public protocol CollapsibleSectionsFetchedResultsControllerDelegate: AnyObject { associatedtype FetchedObject: FetchableObject func controllerWillChangeContent(_ controller: CollapsibleSectionsFetchedResultsController) @@ -68,7 +69,7 @@ public struct CollapsibleResultsSection: Equatab self.isCollapsed = collapsed self.config = config - guard let config = config, collapsed else { + guard let config, collapsed else { displayableObjects = section.objects return } @@ -88,6 +89,8 @@ public struct CollapsibleResultsSection: Equatab } public class CollapsibleSectionsFetchedResultsController: NSObject { + public typealias Delegate = CollapsibleSectionsFetchedResultsControllerDelegate + public typealias BackingFetchController = FetchedResultsController public typealias Section = CollapsibleResultsSection public typealias SectionCollapseCheck = (_ section: BackingFetchController.Section) -> Bool @@ -108,7 +111,7 @@ public class CollapsibleSectionsFetchedResultsController? + private var delegate: DelegateThunk? private var sectionConfigs: [String: SectionCollapseConfig] = [:] @@ -159,19 +162,22 @@ public class CollapsibleSectionsFetchedResultsController(_ delegate: Delegate?) where Delegate.FetchedObject == FetchedObject { + // MARK: - Delegate + + public func setDelegate(_ delegate: (some Delegate)?) { self.delegate = delegate.flatMap { - CollapsibleSectionsFetchResultsDelegate($0) + DelegateThunk($0) } } public func clearDelegate() { - self.delegate = nil + delegate = nil } } // MARK: - Helper Methods public extension CollapsibleSectionsFetchedResultsController { + @MainActor func update(section: CollapsibleResultsSection, maximumNumberOfItemsToDisplay max: Int, whenExceedingMax: Int? = nil) { guard let sectionIndex = sections.firstIndex(of: section) else { return @@ -183,6 +189,7 @@ public extension CollapsibleSectionsFetchedResultsController { } } + @MainActor func expand(section: Section) { guard let sectionIndex = sections.firstIndex(of: section) else { return @@ -193,6 +200,7 @@ public extension CollapsibleSectionsFetchedResultsController { } } + @MainActor func collapse(section: Section) { guard let sectionIndex = sections.firstIndex(of: section) else { return @@ -203,6 +211,7 @@ public extension CollapsibleSectionsFetchedResultsController { } } + @MainActor private func performChanges(onIndex sectionIndex: Int, changes: (Section) -> Void) { let section = sections[sectionIndex] controllerWillChangeContent(fetchController) @@ -255,12 +264,17 @@ public extension CollapsibleSectionsFetchedResultsController { // MARK: - Fetch Methods public extension CollapsibleSectionsFetchedResultsController { - func performFetch(completion: (() -> Void)? = nil) { - fetchController.performFetch(completion: completion ?? {}) + @MainActor + func performFetch(completion: @escaping @MainActor () -> Void = {}) { + fetchController.performFetch(completion: completion) } - func resort(using newSortDescriptors: [NSSortDescriptor], completion: (() -> Void)? = nil) { - fetchController.resort(using: newSortDescriptors, completion: completion ?? {}) + @MainActor + func resort( + using newSortDescriptors: [NSSortDescriptor], + completion: @escaping @MainActor () -> Void = {} + ) { + fetchController.resort(using: newSortDescriptors, completion: completion) } } @@ -392,19 +406,24 @@ extension CollapsibleSectionsFetchedResultsController: FetchedResultsControllerD } } -private class CollapsibleSectionsFetchResultsDelegate: CollapsibleSectionsFetchedResultsControllerDelegate { +// MARK: - DelegateThunk + +private class DelegateThunk { + typealias Parent = CollapsibleSectionsFetchedResultsControllerDelegate typealias Controller = CollapsibleSectionsFetchedResultsController typealias Section = CollapsibleResultsSection - private let willChange: (_ controller: Controller) -> Void - private let didChange: (_ controller: Controller) -> Void + private weak var parent: (any Parent)? - private let changeObject: (_ controller: Controller, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void - private let changeSection: (_ controller: Controller, _ section: Section, _ change: FetchedResultsChange) -> Void + private let willChange: @MainActor (_ controller: Controller) -> Void + private let didChange: @MainActor (_ controller: Controller) -> Void + + private let changeObject: @MainActor (_ controller: Controller, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void + private let changeSection: @MainActor (_ controller: Controller, _ section: Section, _ change: FetchedResultsChange) -> Void + + init(_ parent: some Parent) { + self.parent = parent - init( - _ parent: Parent - ) where Parent.FetchedObject == FetchedObject { willChange = { [weak parent] controller in parent?.controllerWillChangeContent(controller) } @@ -419,7 +438,9 @@ private class CollapsibleSectionsFetchResultsDelegate: Equatable { } } -public protocol FetchedResultsControllerDelegate: AnyObject { +@MainActor +public protocol FetchedResultsControllerDelegate: AnyObject { associatedtype FetchedObject: FetchableObject func controllerWillChangeContent(_ controller: FetchedResultsController) @@ -82,19 +83,24 @@ public extension FetchedResultsControllerDelegate { } } -internal class FetchResultsDelegate: FetchedResultsControllerDelegate { +// MARK: - DelegateThunk + +private class DelegateThunk { + typealias Parent = FetchedResultsControllerDelegate typealias Controller = FetchedResultsController typealias Section = FetchedResultsSection - private let willChange: (_ controller: Controller) -> Void - private let didChange: (_ controller: Controller) -> Void + private weak var parent: (any Parent)? + + private let willChange: @MainActor (_ controller: Controller) -> Void + private let didChange: @MainActor (_ controller: Controller) -> Void - private let changeObject: (_ controller: Controller, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void - private let changeSection: (_ controller: Controller, _ section: Section, _ change: FetchedResultsChange) -> Void + private let changeObject: @MainActor (_ controller: Controller, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void + private let changeSection: @MainActor (_ controller: Controller, _ section: Section, _ change: FetchedResultsChange) -> Void + + init(_ parent: some Parent) { + self.parent = parent - init( - _ parent: Parent - ) where Parent.FetchedObject == FetchedObject { willChange = { [weak parent] controller in parent?.controllerWillChangeContent(controller) } @@ -109,7 +115,9 @@ internal class FetchResultsDelegate: FetchedResu parent?.controller(controller, didChange: section, for: change) } } +} +extension DelegateThunk: FetchedResultsControllerDelegate { func controllerWillChangeContent(_ controller: Controller) { self.willChange(controller) } @@ -163,7 +171,25 @@ public struct FetchedResultsSection: Equatable, // MARK: - FetchedResultsController +func performOnMainThread(async: Bool = true, handler: @escaping @MainActor () -> Void) { + if !Thread.isMainThread { + if async { + DispatchQueue.main.async(execute: handler) + } else { + DispatchQueue.main.sync(execute: handler) + } + } else { + @MainActor(unsafe) + func unsafeHandler() { + handler() + } + unsafeHandler() + } +} + public class FetchedResultsController: NSObject, FetchedResultsControllerProtocol { + public typealias Delegate = FetchedResultsControllerDelegate + public typealias Section = FetchedResultsSection public typealias SectionNameKeyPath = KeyPath @@ -180,7 +206,8 @@ public class FetchedResultsController: NSObject, private var associatedValues: [AssociatedValueKey: AssociatedValueReference] = [:] - private let memoryPressureToken: FetchRequestObservableToken? = { + @MainActor + private lazy var memoryPressureToken: FetchRequestObservableToken? = { #if canImport(UIKit) && !os(watchOS) return FetchRequestObservableToken( token: ObservableNotificationCenterToken(name: UIApplication.didReceiveMemoryWarningNotification) @@ -191,7 +218,7 @@ public class FetchedResultsController: NSObject, }() // swiftlint:disable:next weak_delegate - private var delegate: FetchResultsDelegate? + private var delegate: DelegateThunk? public var associatedFetchSize: Int = 10 @@ -228,11 +255,11 @@ public class FetchedResultsController: NSObject, private lazy var context: Context = { return Context { [weak self] keyPath, objectID in - guard let self = self else { + guard let self else { throw FetchedResultsError.objectNotFound } - return try self.associatedValue(with: keyPath, forObjectID: objectID) + return try self.unsafeAssociatedValue(with: keyPath, forObjectID: objectID) } }() @@ -253,32 +280,27 @@ public class FetchedResultsController: NSObject, } deinit { - if Thread.isMainThread { - reset(emitChanges: false) - } else { - DispatchQueue.main.sync { - reset(emitChanges: false) - } + performOnMainThread(async: false) { + self.reset(emitChanges: false) } } - public func setDelegate< - Delegate: FetchedResultsControllerDelegate - >( - _ delegate: Delegate? - ) where Delegate.FetchedObject == FetchedObject { + // MARK: - Delegate + + public func setDelegate(_ delegate: (some Delegate)?) { self.delegate = delegate.flatMap { - FetchResultsDelegate($0) + DelegateThunk($0) } } public func clearDelegate() { - self.delegate = nil + delegate = nil } // MARK: - Actions @objc + @MainActor private func debouncedReload() { assert(Thread.isMainThread) @@ -290,17 +312,21 @@ public class FetchedResultsController: NSObject, } @objc + @MainActor private func debouncedInsert() { assert(Thread.isMainThread) guard !objectsToInsert.isEmpty else { return } - insert(objectsToInsert) + insert(objectsToInsert) { + // Finished insert + } objectsToInsert.removeAll() } @objc + @MainActor private func debouncedFetch() { assert(Thread.isMainThread) @@ -311,15 +337,24 @@ public class FetchedResultsController: NSObject, // MARK: Fetches public extension FetchedResultsController { - func performFetch(completion: @escaping () -> Void) { + @MainActor + func performFetch(completion: @escaping @MainActor () -> Void) { startObservingNotificationsIfNeeded() definition.request { [weak self] objects in - self?.assign(fetchedObjects: objects, completion: completion) + guard let self else { + completion() + return + } + self.unsafeAssign(fetchedObjects: objects, completion: completion) } } - func resort(using newSortDescriptors: [NSSortDescriptor], completion: @escaping () -> Void) { + @MainActor + func resort( + using newSortDescriptors: [NSSortDescriptor], + completion: @escaping @MainActor () -> Void + ) { assert(Thread.isMainThread) rawSortDescriptors = newSortDescriptors @@ -341,10 +376,12 @@ public extension FetchedResultsController { return indexPathsTable[object] } + @MainActor func reset() { reset(emitChanges: true) } + @MainActor private func reset(emitChanges: Bool) { stopObservingNotifications() removeAll(emitChanges: emitChanges) @@ -354,7 +391,7 @@ public extension FetchedResultsController { // MARK: Associated Values private extension FetchedResultsController { - func associatedValue( + func unsafeAssociatedValue( with keyPath: PartialKeyPath, forObjectID objectID: FetchedObject.ID ) throws -> Any? { @@ -364,11 +401,35 @@ private extension FetchedResultsController { return holder.value } - guard let index = fetchedObjects.firstIndex(where: { $0.id == objectID }) else { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + do { + try self?.fetchAssociatedValues(around: key) + } catch { + CWLogVerbose("Invalid async association request for \(key): \(error)") + } + } + return nil + } else { + try fetchAssociatedValues(around: key) + + // On the off chance that the fetch is synchronous, return the new hash value + let holder = associatedValues[key] + return holder?.value + } + } + + @MainActor(unsafe) + func fetchAssociatedValues( + around key: AssociatedValueKey + ) throws { + assert(Thread.isMainThread) + + guard let index = fetchedObjects.firstIndex(where: { $0.id == key.id }) else { throw FetchedResultsError.objectNotFound } - guard let association = definition.associationsByKeyPath[keyPath] else { + guard let association = definition.associationsByKeyPath[key.keyPath] else { throw FetchedResultsError.objectNotFound } @@ -384,7 +445,7 @@ private extension FetchedResultsController { } let fetchableObjects = objects.filter { let objectID = $0.id - let key = AssociatedValueKey(id: objectID, keyPath: keyPath) + let key = AssociatedValueKey(id: objectID, keyPath: key.keyPath) return associatedValues[key] == nil } @@ -393,7 +454,7 @@ private extension FetchedResultsController { for object in fetchableObjects { // Mark fetchable objects as visited let objectID = object.id - let key = AssociatedValueKey(id: objectID, keyPath: keyPath) + let key = AssociatedValueKey(id: objectID, keyPath: key.keyPath) let reference = association.referenceGenerator(object) valueReferences[key] = reference @@ -401,31 +462,22 @@ private extension FetchedResultsController { } association.request(fetchableObjects) { [weak self] values in - let assign: () -> Void = { + performOnMainThread { self?.assignAssociatedValues( values, - with: keyPath, + with: key.keyPath, for: fetchableObjects, references: valueReferences ) } - - if !Thread.isMainThread { - DispatchQueue.main.async(execute: assign) - } else { - assign() - } } - - // On the off chance that the fetch is synchronous, return the new hash value - let holder = associatedValues[key] - return holder?.value } } // MARK: Contents private extension FetchedResultsController { + @MainActor func assignAssociatedValues( _ values: [FetchedObject.ID: Any], with keyPath: PartialKeyPath, @@ -463,7 +515,7 @@ private extension FetchedResultsController { reference?.observeChanges { [weak self, weak object] invalid in assert(Thread.isMainThread) - guard let object = object else { + guard let object else { return } @@ -476,11 +528,14 @@ private extension FetchedResultsController { } } + @MainActor func removeAssociatedValue( for object: FetchedObject, keyPath: PartialKeyPath, emitChanges: Bool = true ) { + assert(Thread.isMainThread) + let objectID = object.id guard let indexPath = indexPath(for: object) else { return @@ -494,30 +549,54 @@ private extension FetchedResultsController { } } + @MainActor func assign( fetchedObjects objects: [FetchedObject], updateFetchOrder: Bool = true, emitChanges: Bool = true, dropObjectsToInsert: Bool = true, - completion: @escaping () -> Void + completion: @escaping @MainActor () -> Void + ) { + assert(Thread.isMainThread) + + unsafeAssign( + fetchedObjects: objects, + updateFetchOrder: updateFetchOrder, + emitChanges: emitChanges, + dropObjectsToInsert: dropObjectsToInsert, + completion: completion + ) + } + + private func unsafeAssign( + fetchedObjects objects: [FetchedObject], + updateFetchOrder: Bool = true, + emitChanges: Bool = true, + dropObjectsToInsert: Bool = true, + completion: @escaping @MainActor () -> Void ) { guard objects.count <= 100 || !Thread.isMainThread else { // Bounce ourself off of the main queue - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.assign(fetchedObjects: objects, emitChanges: emitChanges, completion: completion) + Task.detached(priority: .userInitiated) { [weak self] in + assert(!Thread.isMainThread) + guard let self else { + performOnMainThread(handler: completion) + return + } + self.unsafeAssign( + fetchedObjects: objects, + emitChanges: emitChanges, + completion: completion + ) } return } let fetchOrder = updateFetchOrder ? OrderedSet(objects.map(\.id)) : fetchedObjectIDs - let sortDescriptors = rawSortDescriptors.finalize(sectionNameKeyPath: sectionNameKeyPath) { id in - fetchOrder.firstIndex(of: id) - } + let sortedObjects = sortedAssignableObjects(objects, fetchOrder: fetchOrder) - let sortedObjects = objects.sorted(by: sortDescriptors) - - func performAssign() { - assign( + performOnMainThread { + self.assign( sortedObjects: sortedObjects, initialOrder: fetchOrder, emitChanges: emitChanges, @@ -525,14 +604,22 @@ private extension FetchedResultsController { ) completion() } + } - if !Thread.isMainThread { - DispatchQueue.main.async(execute: performAssign) - } else { - performAssign() + private func sortedAssignableObjects( + _ objects: C, + fetchOrder: OrderedSet + ) -> [FetchedObject] where C.Element == FetchedObject { + let sortDescriptors = rawSortDescriptors.finalize(sectionNameKeyPath: sectionNameKeyPath) { id in + fetchOrder.firstIndex(of: id) } + + let sortedObjects = objects.sorted(by: sortDescriptors) + + return sortedObjects } + @MainActor func assign( sortedObjects objects: C, initialOrder: OrderedSet, @@ -545,6 +632,11 @@ private extension FetchedResultsController { objectsToInsert.removeAll() } + if objects.isEmpty, fetchedObjects.isEmpty, hasFetchedObjects { + // If our fetch is an empty no-op, just bail + return + } + performChanges(emitChanges: emitChanges) { fetchedObjectIDs = initialOrder @@ -562,6 +654,7 @@ private extension FetchedResultsController { } } + @MainActor func delete(_ object: FetchedObject, emitChanges: Bool = true) throws { guard let indexPath = indexPath(for: object), let fetchIndex = fetchIndex(for: indexPath) else { throw FetchedResultsError.objectNotFound @@ -573,26 +666,28 @@ private extension FetchedResultsController { } } - func insert(_ objects: C, emitChanges: Bool = true) where C.Iterator.Element == FetchedObject { - // This is snapshotted because we're about to be off the main thread + @MainActor + func insert( + _ objects: C, + emitChanges: Bool = true, + completion: @escaping @MainActor () -> Void + ) where C.Element == FetchedObject { + // This is snapshotted because we're potentially about to be off the main thread let fetchedObjectIDs = self.fetchedObjectIDs - guard objects.count <= 100 || !Thread.isMainThread else { - // Bounce ourself off of the main queue - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.insert(objects, fetchedObjectIDs: fetchedObjectIDs, emitChanges: emitChanges) - } - return - } - - insert(objects, fetchedObjectIDs: fetchedObjectIDs, emitChanges: emitChanges) + unsafeInsert( + objects, + fetchedObjectIDs: fetchedObjectIDs, + emitChanges: emitChanges, + completion: completion + ) } - private func insert( + private func sortedInsertableObjects( _ objects: C, - fetchedObjectIDs: OrderedSet, - emitChanges: Bool = true - ) where C.Iterator.Element == FetchedObject { + initialOrder: OrderedSet, + fetchedObjectIDs: OrderedSet + ) -> [FetchedObject] where C.Element == FetchedObject { let initialOrder = OrderedSet(objects.map(\.id)) let fetchOrder = fetchedObjectIDs.union(initialOrder) @@ -607,25 +702,51 @@ private extension FetchedResultsController { return !fetchedObjectIDs.contains(object.id) }.sorted(by: sortDescriptors) - guard !sortedObjects.isEmpty else { + return sortedObjects + } + + private func unsafeInsert( + _ objects: C, + fetchedObjectIDs: OrderedSet, + emitChanges: Bool = true, + completion: @escaping @MainActor () -> Void + ) where C.Element == FetchedObject { + guard objects.count <= 100 || !Thread.isMainThread else { + // Bounce ourself off of the main queue + Task.detached(priority: .userInitiated) { [weak self] in + assert(!Thread.isMainThread) + guard let self else { + performOnMainThread(handler: completion) + return + } + self.unsafeInsert( + objects, + fetchedObjectIDs: fetchedObjectIDs, + emitChanges: emitChanges, + completion: completion + ) + } return } - func performInsert() { - insert( + let initialOrder = OrderedSet(objects.map(\.id)) + let sortedObjects = sortedInsertableObjects( + objects, + initialOrder: initialOrder, + fetchedObjectIDs: fetchedObjectIDs + ) + + performOnMainThread { + self.insert( sortedObjects: sortedObjects, initialOrder: initialOrder, emitChanges: emitChanges ) - } - - if !Thread.isMainThread { - DispatchQueue.main.async(execute: performInsert) - } else { - performInsert() + completion() } } + @MainActor private func insert( sortedObjects objects: C, initialOrder: OrderedSet, @@ -633,6 +754,10 @@ private extension FetchedResultsController { ) where C.Element == FetchedObject { assert(Thread.isMainThread) + guard !objects.isEmpty else { + return + } + performChanges(emitChanges: emitChanges) { fetchedObjectIDs.formUnion(initialOrder) @@ -655,7 +780,11 @@ private extension FetchedResultsController { CWLogVerbose("Inserted \(objects.count) objects") } - func reload(_ objects: C, emitChanges: Bool = true) where C.Iterator.Element == FetchedObject { + @MainActor + func reload( + _ objects: C, + emitChanges: Bool = true + ) where C.Element == FetchedObject { var objectPaths: [FetchedObject: IndexPath] = [:] for object in objects { guard let indexPath = indexPath(for: object) else { @@ -678,6 +807,7 @@ private extension FetchedResultsController { CWLogVerbose("Reloaded \(objects.count) objects") } + @MainActor func move(_ object: FetchedObject, emitChanges: Bool = true) throws { guard let indexPath = indexPath(for: object) else { throw FetchedResultsError.objectNotFound @@ -686,6 +816,7 @@ private extension FetchedResultsController { try move(object, from: indexPath, emitChanges: emitChanges) } + @MainActor func move(_ object: FetchedObject, fromSectionName sectionName: String, emitChanges: Bool = true) throws { guard !sections.isEmpty else { throw FetchedResultsError.objectNotFound @@ -702,6 +833,7 @@ private extension FetchedResultsController { try move(object, from: indexPath, emitChanges: emitChanges) } + @MainActor func move(_ object: FetchedObject, from fromIndexPath: IndexPath, emitChanges: Bool = true) throws { guard let oldFetchIndex = fetchIndex(for: fromIndexPath), object == self.object(at: fromIndexPath) else { throw FetchedResultsError.objectNotFound @@ -776,9 +908,10 @@ private extension FetchedResultsController { } } + @MainActor func removeAll(emitChanges: Bool = true) { performChanges(emitChanges: emitChanges, updateHasFetchedObjects: false) { - if let delegate = delegate, emitChanges { + if let delegate, emitChanges { for (sectionIndex, section) in sections.enumerated().reversed() { for (objectIndex, object) in section.objects.enumerated().reversed() { let indexPath = IndexPath(item: objectIndex, section: sectionIndex) @@ -797,6 +930,7 @@ private extension FetchedResultsController { } } + @MainActor private func rawRemoveAll() { hasFetchedObjects = false fetchedObjects = [] @@ -805,6 +939,7 @@ private extension FetchedResultsController { associatedValues = [:] } + @MainActor func removeAllAssociatedValues(emitChanges: Bool = true) { performChanges(emitChanges: emitChanges) { for (sectionIndex, section) in sections.enumerated() { @@ -818,6 +953,7 @@ private extension FetchedResultsController { } } + @MainActor func remove(_ object: FetchedObject, atIndex index: Int, emitChanges: Bool = true) { guard let indexPath = self.indexPath(forFetchIndex: index) else { return @@ -836,6 +972,7 @@ private extension FetchedResultsController { } } + @MainActor func insert(_ object: FetchedObject, atIndex index: Int, emitChanges: Bool = true) { assert(fetchedObjectIDs.contains(object.id)) @@ -862,6 +999,7 @@ private extension FetchedResultsController { startObserving(object) } + @MainActor func startObserving(_ object: FetchedObject) { assert(Thread.isMainThread) @@ -877,8 +1015,10 @@ private extension FetchedResultsController { observations.append(observer) } - let handleChange: (FetchedObject) -> Void = { [weak self] object in - guard let self = self else { + let handleChange: @MainActor (FetchedObject) -> Void = { [weak self] object in + assert(Thread.isMainThread) + + guard let self else { return } do { @@ -893,14 +1033,14 @@ private extension FetchedResultsController { } let dataObserver = object.observeDataChanges { [weak object] in - guard let object = object else { + guard let object else { return } handleChange(object) } let isDeletedObserver = object.observeIsDeletedChanges { [weak object] in - guard let object = object else { + guard let object else { return } handleChange(object) @@ -908,10 +1048,13 @@ private extension FetchedResultsController { observations += [dataObserver, isDeletedObserver] - let handleSort: (FetchedObject, Bool, Any?, Any?) -> Void = { [weak self] object, isSection, old, new in - guard let self = self else { + let handleSort: @MainActor (FetchedObject, Bool, Any?, Any?) -> Void = { [weak self] object, isSection, old, new in + assert(Thread.isMainThread) + + guard let self else { return } + if let old = old as? NSObject, let new = new as? NSObject { guard old != new else { return @@ -956,6 +1099,7 @@ private extension FetchedResultsController { observationTokens[ObjectIdentifier(object)] = observations } + @MainActor func stopObserving(_ object: FetchedObject) { assert(Thread.isMainThread) @@ -963,23 +1107,31 @@ private extension FetchedResultsController { observationTokens[ObjectIdentifier(object)] = nil } + @MainActor func startObservingNotificationsIfNeeded() { assert(Thread.isMainThread) memoryPressureToken?.observeIfNeeded { [weak self] notification in - self?.removeAllAssociatedValues() + performOnMainThread { + self?.removeAllAssociatedValues() + } } definition.objectCreationToken.observeIfNeeded { [weak self] data in - self?.observedObjectUpdate(data) + performOnMainThread { + self?.observedObjectUpdate(data) + } } for dataResetToken in definition.dataResetTokens { dataResetToken.observeIfNeeded { [weak self] _ in - self?.handleDatabaseClear() + performOnMainThread { + self?.handleDatabaseClear() + } } } } + @MainActor func stopObservingNotifications() { assert(Thread.isMainThread) @@ -995,6 +1147,7 @@ private extension FetchedResultsController { // MARK: - Object Updates private extension FetchedResultsController { + @MainActor func observedObjectUpdate(_ data: FetchedObject.RawData) { guard let id = FetchedObject.entityID(from: data) else { return @@ -1015,6 +1168,7 @@ private extension FetchedResultsController { // MARK: - Debouncing private extension FetchedResultsController { + @MainActor func enqueueReload(of object: FetchedObject, emitChanges: Bool = true) { assert(Thread.isMainThread) @@ -1029,6 +1183,7 @@ private extension FetchedResultsController { perform(#selector(debouncedReload), with: nil, afterDelay: 0) } + @MainActor func enqueueInsert(of object: FetchedObject.RawData, emitChanges: Bool = true) { guard let insertedObject = FetchedObject(data: object) else { return @@ -1036,6 +1191,7 @@ private extension FetchedResultsController { manuallyInsert(objects: [insertedObject], emitChanges: emitChanges) } + @MainActor func handleDatabaseClear() { assert(Thread.isMainThread) @@ -1068,7 +1224,9 @@ extension FetchedResultsController: InternalFetchResultsControllerProtocol { objects.forEach { $0.listenForUpdates() } guard debounceInsertsAndReloads else { - insert(objects, emitChanges: emitChanges) + insert(objects, emitChanges: emitChanges) { + // Finished insert + } return } @@ -1082,6 +1240,7 @@ extension FetchedResultsController: InternalFetchResultsControllerProtocol { // MARK: Delegate Change Events private extension FetchedResultsController { + @MainActor func performChanges( emitChanges: Bool = true, updateHasFetchedObjects: Bool = true, @@ -1118,54 +1277,60 @@ private extension FetchedResultsController { return updatedIndexPaths } + @MainActor func notifyInserting(_ object: FetchedObject, at indexPath: IndexPath, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } delegate.controller(self, didChange: object, for: .insert(location: indexPath)) } + @MainActor func notifyMoving(_ object: FetchedObject, from fromIndexPath: IndexPath, to toIndexPath: IndexPath, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } delegate.controller(self, didChange: object, for: .move(from: fromIndexPath, to: toIndexPath)) } + @MainActor func notifyUpdating(_ object: FetchedObject, at indexPath: IndexPath, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } delegate.controller(self, didChange: object, for: .update(location: indexPath)) } + @MainActor func notifyDeleting(_ object: FetchedObject, at indexPath: IndexPath, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } delegate.controller(self, didChange: object, for: .delete(location: indexPath)) } + @MainActor func notifyInserting(_ section: Section, at sectionIndex: Int, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } delegate.controller(self, didChange: section, for: .insert(location: sectionIndex)) } + @MainActor func notifyDeleting(_ section: Section, at sectionIndex: Int, emitChanges: Bool) { assert(Thread.isMainThread) - guard let delegate = delegate, emitChanges else { + guard let delegate, emitChanges else { return } @@ -1209,7 +1374,7 @@ private extension FetchableObjectProtocol where Self: NSObject { return weakContainer?.value } set { - if let newValue = newValue { + if let newValue { objc_setAssociatedObject(self, &AssociatedKeys.context, Weak(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } else { objc_setAssociatedObject(self, &AssociatedKeys.context, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) @@ -1218,7 +1383,7 @@ private extension FetchableObjectProtocol where Self: NSObject { } func getAssociatedValue(with keyPath: PartialKeyPath) throws -> Value? { - guard let context = context else { + guard let context else { throw FetchedResultsError.objectNotFound } @@ -1373,7 +1538,7 @@ public extension FetchableObjectProtocol where Self: NSObject { } } -private extension Array where Element == NSSortDescriptor { +private extension [NSSortDescriptor] { func finalize( with controller: FetchedResultsController ) -> Self { @@ -1389,7 +1554,7 @@ private extension Array where Element == NSSortDescriptor { ) -> Self { var sortDescriptors = self - if let sectionNameKeyPath = sectionNameKeyPath { + if let sectionNameKeyPath { assert(sectionNameKeyPath._kvcKeyPathString != nil, "\(sectionNameKeyPath) is not KVC compliant?") // Make sure we have our section name included if appropriate diff --git a/FetchRequests/Sources/Controller/FetchedResultsControllerProtocol.swift b/FetchRequests/Sources/Controller/FetchedResultsControllerProtocol.swift index e8f7213..ad90471 100644 --- a/FetchRequests/Sources/Controller/FetchedResultsControllerProtocol.swift +++ b/FetchRequests/Sources/Controller/FetchedResultsControllerProtocol.swift @@ -10,6 +10,7 @@ import Foundation import Combine internal protocol InternalFetchResultsControllerProtocol: FetchedResultsControllerProtocol { + @MainActor func manuallyInsert(objects: [FetchedObject], emitChanges: Bool) } @@ -19,8 +20,9 @@ public protocol DoublyObservableObject: ObservableObject { var objectDidChange: ObjectDidChangePublisher { get } } -public protocol FetchedResultsControllerProtocol: DoublyObservableObject { +public protocol FetchedResultsControllerProtocol: DoublyObservableObject { associatedtype FetchedObject: FetchableObject + typealias SectionNameKeyPath = KeyPath typealias Section = FetchedResultsSection @@ -36,8 +38,11 @@ public protocol FetchedResultsControllerProtocol: DoublyObservableObject { var sectionNameKeyPath: SectionNameKeyPath? { get } var sortDescriptors: [NSSortDescriptor] { get } - func performFetch(completion: @escaping () -> Void) - func resort(using newSortDescriptors: [NSSortDescriptor], completion: @escaping () -> Void) + @MainActor + func performFetch(completion: @escaping @MainActor () -> Void) + @MainActor + func resort(using newSortDescriptors: [NSSortDescriptor], completion: @escaping @MainActor () -> Void) + @MainActor func reset() func indexPath(for object: FetchedObject) -> IndexPath? @@ -46,10 +51,12 @@ public protocol FetchedResultsControllerProtocol: DoublyObservableObject { // MARK: - Index Paths public extension FetchedResultsControllerProtocol { + @MainActor func performFetch() { performFetch(completion: {}) } + @MainActor func resort(using newSortDescriptors: [NSSortDescriptor]) { resort(using: newSortDescriptors, completion: {}) } @@ -192,7 +199,7 @@ public extension FetchedResultsControllerProtocol { // MARK: - Binary Search extension RandomAccessCollection where Index: Strideable { - func binarySearch(matching: (Iterator.Element) -> Bool) -> Self.Index { + func binarySearch(matching: (Element) -> Bool) -> Self.Index { var lowerIndex = startIndex var upperIndex = endIndex @@ -212,7 +219,7 @@ extension RandomAccessCollection where Index: Strideable { extension FetchableObjectProtocol where Self: NSObject { func sectionName(forKeyPath keyPath: KeyPath?) -> String { - guard let keyPath = keyPath else { + guard let keyPath else { return "" } return self[keyPath: keyPath] diff --git a/FetchRequests/Sources/Controller/PausableFetchedResultsController.swift b/FetchRequests/Sources/Controller/PausableFetchedResultsController.swift index 11f2b53..502b3a7 100644 --- a/FetchRequests/Sources/Controller/PausableFetchedResultsController.swift +++ b/FetchRequests/Sources/Controller/PausableFetchedResultsController.swift @@ -9,7 +9,8 @@ import Foundation import Combine -public protocol PausableFetchedResultsControllerDelegate: AnyObject { +@MainActor +public protocol PausableFetchedResultsControllerDelegate: AnyObject { associatedtype FetchedObject: FetchableObject func controllerWillChangeContent(_ controller: PausableFetchedResultsController) @@ -47,11 +48,12 @@ public extension PausableFetchedResultsControllerDelegate { } public class PausableFetchedResultsController { - private let controller: FetchedResultsController - + public typealias Delegate = PausableFetchedResultsControllerDelegate public typealias Section = FetchedResultsSection public typealias SectionNameKeyPath = KeyPath + private let controller: FetchedResultsController + private var hasFetchedObjectsSnapshot: Bool? private var fetchedObjectsSnapshot: [FetchedObject]? private var sectionsSnapshot: [Section]? @@ -90,7 +92,7 @@ public class PausableFetchedResultsController { } // swiftlint:disable:next weak_delegate - private var delegate: PausableFetchResultsDelegate? + private var delegate: DelegateThunk? public init( definition: FetchDefinition, @@ -105,19 +107,36 @@ public class PausableFetchedResultsController { debounceInsertsAndReloads: debounceInsertsAndReloads ) } + + // MARK: - Delegate + + public func setDelegate(_ delegate: (some Delegate)?) { + self.delegate = delegate.flatMap { + DelegateThunk($0, pausableController: self) + } + controller.setDelegate(self.delegate) + } + + public func clearDelegate() { + delegate = nil + controller.clearDelegate() + } } // MARK: - Wrapper Functions extension PausableFetchedResultsController: FetchedResultsControllerProtocol { - public func performFetch(completion: @escaping () -> Void) { + @MainActor + public func performFetch(completion: @escaping @MainActor () -> Void) { controller.performFetch(completion: completion) } - public func resort(using newSortDescriptors: [NSSortDescriptor], completion: @escaping () -> Void) { + @MainActor + public func resort(using newSortDescriptors: [NSSortDescriptor], completion: @escaping @MainActor () -> Void) { controller.resort(using: newSortDescriptors, completion: completion) } + @MainActor public func reset() { controller.reset() isPaused = false @@ -155,18 +174,6 @@ extension PausableFetchedResultsController: FetchedResultsControllerProtocol { public var sections: [Section] { return sectionsSnapshot ?? controller.sections } - - public func setDelegate(_ delegate: Delegate?) where Delegate.FetchedObject == FetchedObject { - self.delegate = delegate.flatMap { - PausableFetchResultsDelegate($0, pausableController: self) - } - controller.setDelegate(self.delegate) - } - - public func clearDelegate() { - self.delegate = nil - controller.clearDelegate() - } } // MARK: - InternalFetchResultsControllerProtocol @@ -177,25 +184,25 @@ extension PausableFetchedResultsController: InternalFetchResultsControllerProtoc } } -// MARK: - PausableFetchResultsDelegate +// MARK: - DelegateThunk -internal class PausableFetchResultsDelegate: FetchedResultsControllerDelegate { +private class DelegateThunk { + typealias Parent = PausableFetchedResultsControllerDelegate typealias ParentController = FetchedResultsController typealias PausableController = PausableFetchedResultsController typealias Section = FetchedResultsSection + private weak var parent: (any Parent)? private weak var pausableController: PausableController? - private let willChange: (_ controller: PausableController) -> Void - private let didChange: (_ controller: PausableController) -> Void + private let willChange: @MainActor (_ controller: PausableController) -> Void + private let didChange: @MainActor (_ controller: PausableController) -> Void - private let changeObject: (_ controller: PausableController, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void - private let changeSection: (_ controller: PausableController, _ section: Section, _ change: FetchedResultsChange) -> Void + private let changeObject: @MainActor (_ controller: PausableController, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void + private let changeSection: @MainActor (_ controller: PausableController, _ section: Section, _ change: FetchedResultsChange) -> Void - init( - _ parent: Parent, - pausableController: PausableController - ) where Parent.FetchedObject == FetchedObject { + init(_ parent: some Parent, pausableController: PausableController) { + self.parent = parent self.pausableController = pausableController willChange = { [weak parent] controller in @@ -212,20 +219,22 @@ internal class PausableFetchResultsDelegate: Fet parent?.controller(controller, didChange: section, for: change) } } +} +extension DelegateThunk: FetchedResultsControllerDelegate { func controllerWillChangeContent(_ controller: ParentController) { - guard let pausableController = pausableController, !pausableController.isPaused else { + guard let pausableController, !pausableController.isPaused else { return } pausableController.objectWillChangeSubject.send() - self.willChange(pausableController) + self.controllerWillChangeContent(pausableController) } func controllerDidChangeContent(_ controller: ParentController) { - guard let pausableController = pausableController, !pausableController.isPaused else { + guard let pausableController, !pausableController.isPaused else { return } - self.didChange(pausableController) + self.controllerDidChangeContent(pausableController) pausableController.objectDidChangeSubject.send() } @@ -234,10 +243,10 @@ internal class PausableFetchResultsDelegate: Fet didChange object: FetchedObject, for change: FetchedResultsChange ) { - guard let pausableController = pausableController, !pausableController.isPaused else { + guard let pausableController, !pausableController.isPaused else { return } - self.changeObject(pausableController, object, change) + self.controller(pausableController, didChange: object, for: change) } func controller( @@ -245,9 +254,35 @@ internal class PausableFetchResultsDelegate: Fet didChange section: Section, for change: FetchedResultsChange ) { - guard let pausableController = pausableController, !pausableController.isPaused else { + guard let pausableController, !pausableController.isPaused else { return } - self.changeSection(pausableController, section, change) + self.controller(pausableController, didChange: section, for: change) + } +} + +extension DelegateThunk: PausableFetchedResultsControllerDelegate { + public func controllerWillChangeContent(_ controller: PausableController) { + self.willChange(controller) + } + + public func controllerDidChangeContent(_ controller: PausableController) { + self.didChange(controller) + } + + public func controller( + _ controller: PausableController, + didChange object: FetchedObject, + for change: FetchedResultsChange + ) { + self.changeObject(controller, object, change) + } + + public func controller( + _ controller: PausableController, + didChange section: FetchedResultsSection, + for change: FetchedResultsChange + ) { + self.changeSection(controller, section, change) } } diff --git a/FetchRequests/Sources/FetchableObject.swift b/FetchRequests/Sources/FetchableObject.swift index 639e2a1..91fc465 100644 --- a/FetchRequests/Sources/FetchableObject.swift +++ b/FetchRequests/Sources/FetchableObject.swift @@ -12,7 +12,7 @@ import Foundation public typealias FetchableObject = NSObject & FetchableObjectProtocol /// A class of types whose instances hold raw data of that entity -public protocol RawDataRepresentable { +public protocol RawDataRepresentable { associatedtype RawData /// Initialize a fetchable object from raw data @@ -34,10 +34,10 @@ public protocol FetchableObjectProtocol: NSObjectProtocol, Identifiable, RawData static func entityID(from data: RawData) -> ID? /// Listen for changes to the underlying data of `self` - func observeDataChanges(_ handler: @escaping () -> Void) -> InvalidatableToken + func observeDataChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken /// Listen for changes to whether `self` is deleted - func observeIsDeletedChanges(_ handler: @escaping () -> Void) -> InvalidatableToken + func observeIsDeletedChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken /// Enforce listening for changes to the underlying data of `self` func listenForUpdates() diff --git a/FetchRequests/Sources/JSON.swift b/FetchRequests/Sources/JSON.swift index 1f2b819..a77929f 100644 --- a/FetchRequests/Sources/JSON.swift +++ b/FetchRequests/Sources/JSON.swift @@ -22,33 +22,23 @@ public enum JSON { public init?(_ value: Any) { if let value = value as? Data { self.init(data: value) - } else if let value = value as? JSON { - self = value - } else if let value = value as? String { - self = .string(value) - } else if let value = value as? NSNumber, value.isBool { - self = .bool(value.boolValue) - } else if let value = value as? NSNumber { - self = .number(value) - } else if let value = value as? [JSON] { - self = .array(value.map { $0.object }) - } else if let value = value as? [Any] { - self = .array(value) - } else if let value = value as? [String: JSON] { - self = .dictionary( - value.reduce(into: [:]) { memo, kvp in - memo[kvp.key] = kvp.value.object - } - ) + } else if let value = value as? JSONConvertible { + self.init(value) } else if let value = value as? [String: Any] { + // This intentionally does not deeply evaluate child values self = .dictionary(value) - } else if let _ = value as? NSNull { - self = .null + } else if let value = value as? [Any] { + // This intentionally does not deeply evaluate child values + self = .array(value) } else { return nil } } + public init(_ value: JSONConvertible) { + self = value.jsonRepresentation() + } + public init?( data: Data, options: JSONSerialization.ReadingOptions = [] @@ -385,42 +375,44 @@ extension JSON: Collection { // MARK: - Literals extension JSON: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (String, Any)...) { + public init(dictionaryLiteral elements: (String, JSONConvertible)...) { + // This should be consistent with [String: JSONConvertible].jsonRepresentation() let data: [String: Any] = elements.reduce(into: [:]) { memo, element in - memo[element.0] = element.1 + memo[element.0] = element.1.jsonRepresentation().object } self = .dictionary(data) } } extension JSON: ExpressibleByArrayLiteral { - public init(arrayLiteral elements: Any...) { - let data: [Any] = elements + public init(arrayLiteral elements: JSONConvertible...) { + // This should be consistent with [JSONConvertible].jsonRepresentation() + let data: [Any] = elements.map { $0.jsonRepresentation().object } self = .array(data) } } extension JSON: ExpressibleByStringLiteral { public init(stringLiteral value: String) { - self = .string(value) + self.init(value) } } extension JSON: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { - self = .bool(value) + self.init(value) } } extension JSON: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { - self = .number(NSNumber(value: value)) + self.init(value) } } extension JSON: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { - self = .number(NSNumber(value: value)) + self.init(value) } } @@ -436,7 +428,7 @@ public enum JSONError: Error { case invalidContent } -private extension Dictionary where Key == String, Value == Any { +private extension [String: Any] { func encodableDictionary() throws -> [String: JSON] { return try reduce(into: [:]) { memo, kvp in guard let value = JSON(kvp.value) else { @@ -447,7 +439,7 @@ private extension Dictionary where Key == String, Value == Any { } } -private extension Array where Element == Any { +private extension [Any] { func encodableArray() throws -> [JSON] { return try map { element in guard let value = JSON(element) else { @@ -499,7 +491,7 @@ extension JSON: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - let object: Any + let object: JSONConvertible if container.decodeNil() { object = NSNull() @@ -511,57 +503,34 @@ extension JSON: Decodable { object = array } else if let dictionary = try? container.decode([String: JSON].self) { object = dictionary + } else if let int = try? container.decode(Int64.self) { + object = int + } else if let int = try? container.decode(Int32.self) { + object = int + } else if let int = try? container.decode(Int16.self) { + object = int + } else if let int = try? container.decode(Int8.self) { + object = int + } else if let int = try? container.decode(Int.self) { + object = int + } else if let int = try? container.decode(UInt64.self) { + object = int + } else if let int = try? container.decode(UInt32.self) { + object = int + } else if let int = try? container.decode(UInt16.self) { + object = int + } else if let int = try? container.decode(UInt8.self) { + object = int + } else if let int = try? container.decode(UInt.self) { + object = int + } else if let double = try? container.decode(Double.self) { + object = double + } else if let float = try? container.decode(Float.self) { + object = float } else { - var signedNumber: NSNumber? { - if let int = try? container.decode(Int64.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(Int32.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(Int16.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(Int8.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(Int.self) { - return NSNumber(value: int) - } else { - return nil - } - } - var unsignedNumber: NSNumber? { - if let int = try? container.decode(UInt64.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(UInt32.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(UInt16.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(UInt8.self) { - return NSNumber(value: int) - } else if let int = try? container.decode(UInt.self) { - return NSNumber(value: int) - } else { - return nil - } - } - var floatingPointNumber: NSNumber? { - if let double = try? container.decode(Double.self) { - return NSNumber(value: double) - } else if let float = try? container.decode(Float.self) { - return NSNumber(value: float) - } else { - return nil - } - } - - guard let number = signedNumber ?? unsignedNumber ?? floatingPointNumber else { - throw JSONError.invalidContent - } - object = number - } - - guard let data = JSON(object) else { throw JSONError.invalidContent } - self = data + self = object.jsonRepresentation() } } @@ -595,3 +564,137 @@ private extension NSNumber { case boolean } } + +// MARK: - JSONConvertible + +public protocol JSONConvertible { + func jsonRepresentation() -> JSON +} + +extension JSON: JSONConvertible { + public func jsonRepresentation() -> JSON { + return self + } +} + +extension Array: JSONConvertible where Element: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .array(map { $0.jsonRepresentation().object }) + } +} + +extension Dictionary: JSONConvertible where Key == String, Value: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .dictionary( + reduce(into: [:]) { memo, kvp in + memo[kvp.key] = kvp.value.jsonRepresentation().object + } + ) + } +} + +extension String: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .string(self) + } +} + +extension NSString: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .string(self as String) + } +} + +extension NSNull: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .null + } +} + +extension NSNumber: JSONConvertible { + public func jsonRepresentation() -> JSON { + if self.isBool { + return .bool(boolValue) + } else { + return .number(self) + } + } +} + +extension Bool: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .bool(self) + } +} + +extension Int64: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Int32: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Int16: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Int8: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Int: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension UInt64: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension UInt32: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension UInt16: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension UInt8: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension UInt: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Double: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} + +extension Float: JSONConvertible { + public func jsonRepresentation() -> JSON { + return .number(NSNumber(value: self)) + } +} diff --git a/FetchRequests/Sources/Requests/FetchDefinition.swift b/FetchRequests/Sources/Requests/FetchDefinition.swift index bcff4b0..4865151 100644 --- a/FetchRequests/Sources/Requests/FetchDefinition.swift +++ b/FetchRequests/Sources/Requests/FetchDefinition.swift @@ -9,8 +9,8 @@ import Foundation public class FetchDefinition { - public typealias Request = (_ completion: @escaping ([FetchedObject]) -> Void) -> Void - public typealias CreationInclusionCheck = (_ rawData: FetchedObject.RawData) -> Bool + public typealias Request = @MainActor (_ completion: @escaping ([FetchedObject]) -> Void) -> Void + public typealias CreationInclusionCheck = @MainActor (_ rawData: FetchedObject.RawData) -> Bool internal let request: Request internal let objectCreationToken: FetchRequestObservableToken @@ -20,13 +20,16 @@ public class FetchDefinition { internal let associationsByKeyPath: [FetchRequestAssociation.AssociationKeyPath: FetchRequestAssociation] - public init( + public init< + VoidToken: ObservableToken, + DataToken: ObservableToken + >( request: @escaping Request, objectCreationToken: DataToken, creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, associations: [FetchRequestAssociation] = [], dataResetTokens: [VoidToken] = [] - ) where VoidToken.Parameter == Void, DataToken.Parameter == FetchedObject.RawData { + ) { self.request = request self.objectCreationToken = FetchRequestObservableToken(token: objectCreationToken) self.creationInclusionCheck = creationInclusionCheck diff --git a/FetchRequests/Sources/Requests/PaginatingFetchDefinition.swift b/FetchRequests/Sources/Requests/PaginatingFetchDefinition.swift index c7bbab4..d2a08ea 100644 --- a/FetchRequests/Sources/Requests/PaginatingFetchDefinition.swift +++ b/FetchRequests/Sources/Requests/PaginatingFetchDefinition.swift @@ -9,21 +9,24 @@ import Foundation public class PaginatingFetchDefinition: FetchDefinition { - public typealias PaginationRequest = ( + public typealias PaginationRequest = @MainActor ( _ currentResults: [FetchedObject], _ completion: @escaping ([FetchedObject]?) -> Void ) -> Void internal let paginationRequest: PaginationRequest - public init( + public init< + VoidToken: ObservableToken, + DataToken: ObservableToken + >( request: @escaping Request, paginationRequest: @escaping PaginationRequest, objectCreationToken: DataToken, creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, associations: [FetchRequestAssociation] = [], dataResetTokens: [VoidToken] = [] - ) where VoidToken.Parameter == Void, DataToken.Parameter == FetchedObject.RawData { + ) { self.paginationRequest = paginationRequest super.init( request: request, @@ -36,15 +39,18 @@ public class PaginatingFetchDefinition: FetchDef } private extension InternalFetchResultsControllerProtocol { + @MainActor func performPagination( with paginationRequest: PaginatingFetchDefinition.PaginationRequest ) { let currentResults = self.fetchedObjects paginationRequest(currentResults) { [weak self] pageResults in - guard let pageResults = pageResults else { + guard let pageResults else { return } - self?.manuallyInsert(objects: pageResults, emitChanges: true) + performOnMainThread { + self?.manuallyInsert(objects: pageResults, emitChanges: true) + } } } } @@ -70,6 +76,7 @@ public class PaginatingFetchedResultsController< ) } + @MainActor public func performPagination() { performPagination(with: paginatingDefinition.paginationRequest) } @@ -96,6 +103,7 @@ public class PausablePaginatingFetchedResultsController< ) } + @MainActor public func performPagination() { performPagination(with: paginatingDefinition.paginationRequest) } diff --git a/FetchRequests/Sources/SwiftUI/FetchableRequest.swift b/FetchRequests/Sources/SwiftUI/FetchableRequest.swift index 3b380f7..107c47d 100644 --- a/FetchRequests/Sources/SwiftUI/FetchableRequest.swift +++ b/FetchRequests/Sources/SwiftUI/FetchableRequest.swift @@ -39,6 +39,7 @@ public struct SectionedFetchableRequest: Dynamic _base = FetchableRequest(controller: controller, animation: animation) } + @MainActor(unsafe) public mutating func update() { _base.update() } @@ -90,6 +91,7 @@ public struct FetchableRequest: DynamicProperty self.animation = animation } + @MainActor(unsafe) public mutating func update() { _wrappedValue.update() _fetchController.update() @@ -107,7 +109,7 @@ public struct FetchableRequest: DynamicProperty let animation = self.animation subscription.value = fetchController.objectDidChange.sink { [weak controller] in - guard let controller = controller else { + guard let controller else { return } withAnimation(animation) { diff --git a/FetchRequests/Tests/Controllers/CollapsibleSectionsFetchedResultsControllerTestCase.swift b/FetchRequests/Tests/Controllers/CollapsibleSectionsFetchedResultsControllerTestCase.swift index 4f13f21..07a187e 100644 --- a/FetchRequests/Tests/Controllers/CollapsibleSectionsFetchedResultsControllerTestCase.swift +++ b/FetchRequests/Tests/Controllers/CollapsibleSectionsFetchedResultsControllerTestCase.swift @@ -9,9 +9,9 @@ import XCTest @testable import FetchRequests -// swiftlint:disable force_try implicitly_unwrapped_optional +// swiftlint:disable force_try implicitly_unwrapped_optional type_name -// swiftlint:disable:next type_name +@MainActor class CollapsibleSectionsFetchedResultsControllerTestCase: XCTestCase { typealias FetchController = CollapsibleSectionsFetchedResultsController @@ -39,8 +39,8 @@ class CollapsibleSectionsFetchedResultsControllerTestCase: XCTestCase { self.associationRequest = associationRequest } - let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] json in - return self.inclusionCheck?(json) ?? true + let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] rawData in + return self.inclusionCheck?(rawData) ?? true } return FetchDefinition( @@ -819,8 +819,8 @@ extension CollapsibleSectionsFetchedResultsControllerTestCase { // Broadcast tagID 0 - inclusionCheck = { json in - TestObject.entityID(from: json) != "0" + inclusionCheck = { rawData in + TestObject.entityID(from: rawData) != "0" } let updateName = TestObject.objectWasCreated() @@ -1255,8 +1255,8 @@ extension CollapsibleSectionsFetchedResultsControllerTestCase { let newObject = TestObject(id: "d") - inclusionCheck = { json in - TestObject.entityID(from: json) != newObject.id + inclusionCheck = { rawData in + TestObject.entityID(from: rawData) != newObject.id } let update = newObject.data @@ -1313,24 +1313,24 @@ private extension CollapsibleSectionsFetchedResultsControllerTestCase { extension CollapsibleSectionsFetchedResultsController where FetchedObject: TestObject { var fetchedIDs: [String] { - return fetchedObjects.compactMap { $0.id } + return fetchedObjects.map(\.id) } var tags: [Int] { - return fetchedObjects.compactMap { $0.tag } + return fetchedObjects.map(\.tag) } } extension CollapsibleResultsSection where FetchedObject: TestObject { var allFetchedIDs: [String] { - return allObjects.compactMap { $0.id } + return allObjects.map(\.id) } var displayableFetchedIDs: [String] { - return displayableObjects.compactMap { $0.id } + return displayableObjects.map(\.id) } var allTags: [Int] { - return allObjects.compactMap { $0.tag } + return allObjects.map(\.tag) } } diff --git a/FetchRequests/Tests/Controllers/FetchedResultsControllerTestCase.swift b/FetchRequests/Tests/Controllers/FetchedResultsControllerTestCase.swift index 3d6469f..0628210 100644 --- a/FetchRequests/Tests/Controllers/FetchedResultsControllerTestCase.swift +++ b/FetchRequests/Tests/Controllers/FetchedResultsControllerTestCase.swift @@ -10,6 +10,7 @@ import XCTest @testable import FetchRequests // swiftlint:disable force_try implicitly_unwrapped_optional +@MainActor class FetchedResultsControllerTestCase: XCTestCase, FetchedResultsControllerTestHarness { private(set) var controller: FetchedResultsController! @@ -34,8 +35,8 @@ class FetchedResultsControllerTestCase: XCTestCase, FetchedResultsControllerTest self.associationRequest = associationRequest } - let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] json in - return self.inclusionCheck?(json) ?? true + let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] rawData in + return self.inclusionCheck?(rawData) ?? true } return FetchDefinition( @@ -501,8 +502,8 @@ extension FetchedResultsControllerTestCase { // Broadcast tagID 0 - inclusionCheck = { json in - TestObject.entityID(from: json) != "0" + inclusionCheck = { rawData in + TestObject.entityID(from: rawData) != "0" } let updateName = TestObject.objectWasCreated() @@ -939,8 +940,8 @@ extension FetchedResultsControllerTestCase { let newObject = TestObject(id: "d") - inclusionCheck = { json in - TestObject.entityID(from: json) != newObject.id + inclusionCheck = { rawData in + TestObject.entityID(from: rawData) != newObject.id } let update = newObject.data diff --git a/FetchRequests/Tests/Controllers/FetchedResultsControllerTestHarness.swift b/FetchRequests/Tests/Controllers/FetchedResultsControllerTestHarness.swift index bf6e320..dcb31bf 100644 --- a/FetchRequests/Tests/Controllers/FetchedResultsControllerTestHarness.swift +++ b/FetchRequests/Tests/Controllers/FetchedResultsControllerTestHarness.swift @@ -12,6 +12,7 @@ import Foundation // swiftlint:disable implicitly_unwrapped_optional +@MainActor protocol FetchedResultsControllerTestHarness { associatedtype FetchController: FetchedResultsControllerProtocol where FetchController.FetchedObject == TestObject @@ -21,12 +22,14 @@ protocol FetchedResultsControllerTestHarness { } extension FetchedResultsControllerTestHarness { + @MainActor func performFetch(_ objectIDs: [String], file: StaticString = #file, line: UInt = #line) throws { let objects = objectIDs.compactMap { TestObject(id: $0) } try performFetch(objects, file: file, line: line) } + @MainActor func performFetch(_ objects: [TestObject], file: StaticString = #file, line: UInt = #line) throws { controller.performFetch() @@ -47,20 +50,20 @@ extension FetchedResultsControllerTestHarness { extension FetchedResultsController where FetchedObject: TestObject { var fetchedIDs: [String] { - return fetchedObjects.map { $0.id } + return fetchedObjects.map(\.id) } var tags: [Int] { - return fetchedObjects.map { $0.tag } + return fetchedObjects.map(\.tag) } } extension FetchedResultsSection where FetchedObject: TestObject { var fetchedIDs: [String] { - return objects.map { $0.id } + return objects.map(\.id) } var tags: [Int] { - return objects.map { $0.tag } + return objects.map(\.tag) } } diff --git a/FetchRequests/Tests/Controllers/PaginatingFetchedResultsControllerTestCase.swift b/FetchRequests/Tests/Controllers/PaginatingFetchedResultsControllerTestCase.swift index 63bf31e..fa35695 100644 --- a/FetchRequests/Tests/Controllers/PaginatingFetchedResultsControllerTestCase.swift +++ b/FetchRequests/Tests/Controllers/PaginatingFetchedResultsControllerTestCase.swift @@ -42,8 +42,8 @@ class PaginatingFetchedResultsControllerTestCase: XCTestCase, FetchedResultsCont self.associationRequest = associationRequest } - let inclusionCheck: PaginatingFetchDefinition.CreationInclusionCheck = { [unowned self] json in - return self.inclusionCheck?(json) ?? true + let inclusionCheck: PaginatingFetchDefinition.CreationInclusionCheck = { [unowned self] rawData in + return self.inclusionCheck?(rawData) ?? true } return PaginatingFetchDefinition( diff --git a/FetchRequests/Tests/Controllers/PausableFetchedResultsControllerTestCase.swift b/FetchRequests/Tests/Controllers/PausableFetchedResultsControllerTestCase.swift index b85ebc2..5709bde 100644 --- a/FetchRequests/Tests/Controllers/PausableFetchedResultsControllerTestCase.swift +++ b/FetchRequests/Tests/Controllers/PausableFetchedResultsControllerTestCase.swift @@ -35,8 +35,8 @@ class PausableFetchedResultsControllerTestCase: XCTestCase, FetchedResultsContro self.associationRequest = associationRequest } - let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] json in - return self.inclusionCheck?(json) ?? true + let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] rawData in + return self.inclusionCheck?(rawData) ?? true } return FetchDefinition( @@ -201,14 +201,14 @@ class PausableFetchedResultsControllerTestCase: XCTestCase, FetchedResultsContro let effectiveSortDescriptorKeys = [ #selector(getter: TestObject.sectionName), NSSelectorFromString("self"), - ].map { $0.description } + ].map(\.description) try! performFetch(["a", "b", "c"]) controller.associatedFetchSize = 20 XCTAssert(controller.definition === fetchDefinition) - XCTAssertEqual(controller.sortDescriptors.map { $0.key }, effectiveSortDescriptorKeys) + XCTAssertEqual(controller.sortDescriptors.map(\.key), effectiveSortDescriptorKeys) XCTAssertEqual(controller.sectionNameKeyPath, \TestObject.sectionName) XCTAssertEqual(controller.associatedFetchSize, 20) XCTAssertTrue(controller.hasFetchedObjects) diff --git a/FetchRequests/Tests/Models/FetchRequestAssociationTestCase.swift b/FetchRequests/Tests/Models/FetchRequestAssociationTestCase.swift index 7770f1c..38535ec 100644 --- a/FetchRequests/Tests/Models/FetchRequestAssociationTestCase.swift +++ b/FetchRequests/Tests/Models/FetchRequestAssociationTestCase.swift @@ -9,21 +9,22 @@ import XCTest @testable import FetchRequests +@MainActor class FetchRequestAssociationTestCase: XCTestCase { typealias Association = FetchRequestAssociation private var objects: [TestObject] = [] private var objectIDs: [String] { - return objects.map { $0.id } + return objects.map(\.id) } private var tags: [Int] { - return objects.map { $0.tag } + return objects.map(\.tag) } private var tagIDs: [String] { - return objects.map { $0.nonOptionalTagID } + return objects.map(\.nonOptionalTagID) } override func setUp() { @@ -766,7 +767,7 @@ private final class TestFetchableEntityID: NSObject, FetchableEntityID { } class func fetch(byIDs objectIDs: [TestFetchableEntityID]) -> [TestObject] { - return TestObject.fetch(byIDs: objectIDs.map { $0.id }) + return TestObject.fetch(byIDs: objectIDs.map(\.id)) } class func fetch(byIDs objectIDs: [TestFetchableEntityID], completion: @escaping ([TestObject]) -> Void) { diff --git a/FetchRequests/Tests/Models/JSONTestCase.swift b/FetchRequests/Tests/Models/JSONTestCase.swift index 85d7513..c70d02c 100644 --- a/FetchRequests/Tests/Models/JSONTestCase.swift +++ b/FetchRequests/Tests/Models/JSONTestCase.swift @@ -39,7 +39,7 @@ extension JSONTestCase { XCTAssertTrue(enumValue.bool ?? false) XCTAssertTrue(boolRepresentable.bool ?? false) - XCTAssertTrue(nsnumberBoolInit?.bool ?? false) + XCTAssertTrue(nsnumberBoolInit.bool ?? false) XCTAssertEqual(enumValue, nsnumberBoolInit) XCTAssertEqual(enumValue, boolRepresentable) XCTAssertNotEqual(nsnumberBoolInit, nsnumberNumberInit) @@ -84,7 +84,7 @@ extension JSONTestCase { XCTAssertEqual(enumValue, stringRepresentable) XCTAssertEqual(enumValue.string, string) XCTAssertEqual(stringRepresentable.string, string) - XCTAssertEqual(stringInit?.string, string) + XCTAssertEqual(stringInit.string, string) } func testArray() { @@ -112,6 +112,19 @@ extension JSONTestCase { XCTAssertEqual(dictRepresentable.dictionary as NSDictionary?, dict as NSDictionary) XCTAssertEqual(dictInit?.dictionary as NSDictionary?, dict as NSDictionary) } + + func testConvertible() { + let myValue: UInt64 = 1 + + let jsonArray: JSON = [myValue] + let jsonDictionary: JSON = [ + "key": [myValue], + "key2": jsonArray, + ] + + XCTAssertEqual(jsonArray[0], myValue.jsonRepresentation()) + XCTAssertEqual(jsonDictionary.key?[0], myValue.jsonRepresentation()) + } } // MARK: - Subscripts @@ -213,7 +226,7 @@ extension JSONTestCase { XCTAssertEqual(value.count, 1) - XCTAssertEqual(value.compactMap { $0.value.bool }, [true]) + XCTAssertEqual(value.compactMap(\.value.bool), [true]) value[.value(isStart: true)] = "abc" @@ -411,6 +424,7 @@ extension JSONTestCase { func testJsonNSObjectForBoxedJSON() { let dict = NSDictionary(dictionary: ["name": "Alexa"]) let boxed = BoxedJSON(__object: dict) - XCTAssertTrue(boxed?.json.name?.string == "Alexa") + XCTAssertNotNil(boxed) + XCTAssertEqual(boxed?.json.name?.string, "Alexa") } } diff --git a/FetchRequests/Tests/SwiftUI/FetchableRequestTestCase.swift b/FetchRequests/Tests/SwiftUI/FetchableRequestTestCase.swift index b89bd7b..bc6079f 100644 --- a/FetchRequests/Tests/SwiftUI/FetchableRequestTestCase.swift +++ b/FetchRequests/Tests/SwiftUI/FetchableRequestTestCase.swift @@ -10,6 +10,7 @@ import XCTest @testable import FetchRequests +@MainActor class FetchableRequestTestCase: XCTestCase { } diff --git a/FetchRequests/Tests/TestObject+Associations.swift b/FetchRequests/Tests/TestObject+Associations.swift index 2460b73..5da4678 100644 --- a/FetchRequests/Tests/TestObject+Associations.swift +++ b/FetchRequests/Tests/TestObject+Associations.swift @@ -35,14 +35,14 @@ extension TestObject { extension TestObject { enum AssociationRequest { - case parents([TestObject], completion: ([String: String]) -> Void) - case tagIDs([String], completion: ([TestObject]) -> Void) + case parents([TestObject], completion: @MainActor ([String: String]) -> Void) + case tagIDs([String], completion: @MainActor ([TestObject]) -> Void) var parentIDs: [String]! { guard case let .parents(objects, _) = self else { return nil } - return objects.map { $0.id } + return objects.map(\.id) } var tagIDs: [String]! { @@ -52,14 +52,14 @@ extension TestObject { return objects } - var parentsCompletion: (([String: String]) -> Void)! { + var parentsCompletion: (@MainActor ([String: String]) -> Void)! { guard case let .parents(_, completion) = self else { return nil } return completion } - var tagIDsCompletion: (([TestObject]) -> Void)! { + var tagIDsCompletion: (@MainActor ([TestObject]) -> Void)! { guard case let .tagIDs(_, completion) = self else { return nil } @@ -69,7 +69,7 @@ extension TestObject { static func fetchRequestAssociations( matching: [PartialKeyPath], - request: @escaping (AssociationRequest) -> Void + request: @escaping @MainActor (AssociationRequest) -> Void ) -> [FetchRequestAssociation] { let tagString = FetchRequestAssociation( keyPath: \.tag, @@ -180,8 +180,8 @@ extension FetchRequestAssociation where FetchedObject == TestObject { creationTokenGenerator: { objectID in return TestEntityObservableToken( name: AssociatedType.objectWasCreated(), - include: { json in - guard let includeID = AssociatedType.entityID(from: json) else { + include: { rawData in + guard let includeID = AssociatedType.entityID(from: rawData) else { return false } return objectID == includeID @@ -204,8 +204,8 @@ extension FetchRequestAssociation where FetchedObject == TestObject { creationTokenGenerator: { objectIDs in return TestEntityObservableToken( name: AssociatedType.objectWasCreated(), - include: { json in - guard let objectID = AssociatedType.entityID(from: json) else { + include: { rawData in + guard let objectID = AssociatedType.entityID(from: rawData) else { return false } return objectIDs.contains(objectID) diff --git a/FetchRequests/Tests/TestObject+FetchableObject.swift b/FetchRequests/Tests/TestObject+FetchableObject.swift index 2078b93..d7c9e02 100644 --- a/FetchRequests/Tests/TestObject+FetchableObject.swift +++ b/FetchRequests/Tests/TestObject+FetchableObject.swift @@ -12,17 +12,18 @@ import FetchRequests // MARK: - FetchableObjectProtocol extension TestObject: FetchableObjectProtocol { - func observeDataChanges(_ handler: @escaping () -> Void) -> InvalidatableToken { - return self.observe(\.data, options: [.old, .new]) { object, change in + func observeDataChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { + return self.observe(\.data, options: [.old, .new]) { @MainActor(unsafe) object, change in guard let old = change.oldValue, let new = change.newValue, old != new else { return } + handler() } } - func observeIsDeletedChanges(_ handler: @escaping () -> Void) -> InvalidatableToken { - return self.observe(\.isDeleted, options: [.old, .new]) { object, change in + func observeIsDeletedChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { + return self.observe(\.isDeleted, options: [.old, .new]) { @MainActor(unsafe) object, change in guard let old = change.oldValue, let new = change.newValue, old != new else { return } diff --git a/Package.swift b/Package.swift index 17ee768..766e0f0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,14 +1,15 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.7 import PackageDescription let package = Package( name: "FetchRequests", platforms: [ - .macOS(.v10_15), + .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), + .macOS(.v10_15), ], products: [ .library( @@ -26,7 +27,7 @@ let package = Package( .product(name: "Collections", package: "swift-collections"), ], path: "FetchRequests", - exclude: ["Tests"] + exclude: ["Tests", "Info.plist", "TestsInfo.plist"] ), .testTarget( name: "FetchRequestsTests", diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift deleted file mode 100644 index 30945a4..0000000 --- a/Package@swift-5.5.swift +++ /dev/null @@ -1,39 +0,0 @@ -// swift-tools-version:5.5 - -import PackageDescription - -let package = Package( - name: "FetchRequests", - platforms: [ - .macOS(.v10_15), - .macCatalyst(.v13), - .iOS(.v13), - .tvOS(.v13), - .watchOS(.v6), - ], - products: [ - .library( - name: "FetchRequests", - targets: ["FetchRequests"] - ), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), - ], - targets: [ - .target( - name: "FetchRequests", - dependencies: [ - .product(name: "Collections", package: "swift-collections"), - ], - path: "FetchRequests", - exclude: ["Tests", "Info.plist", "TestsInfo.plist"] - ), - .testTarget( - name: "FetchRequestsTests", - dependencies: ["FetchRequests"], - path: "FetchRequests/Tests" - ), - ], - swiftLanguageVersions: [.v5] -) diff --git a/README.md b/README.md index 1b5465b..a6d93f8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Otherwise we will use a [less efficient OrderedSet](https://github.com/square/Fe Install with [CocoaPods](http://cocoapods.org) by specifying the following in your `Podfile`: ```ruby -pod 'FetchRequests', '~> 4.0' +pod 'FetchRequests', '~> 5.0' ``` ### Carthage @@ -124,7 +124,7 @@ pod 'FetchRequests', '~> 4.0' Install with [Carthage](https://github.com/Carthage/Carthage) by specify the following in your `Cartfile`: ``` -github "square/FetchRequests" ~> 4.0 +github "square/FetchRequests" ~> 5.0 ``` ### Swift Package Manager @@ -133,7 +133,7 @@ Install with [Swift Package Manager](https://swift.org/package-manager/) by addi ```swift dependencies: [ - .package(url: "https://github.com/square/FetchRequests.git", from: "4.0.0") + .package(url: "https://github.com/square/FetchRequests.git", from: "5.0.0") ] ```