├── codecov.yml ├── Example ├── iOS-Example │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Atomic.swift │ ├── Info.plist │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── ObservableValue.swift │ ├── Model+Fetchable.swift │ ├── SwiftUIView.swift │ ├── Model+Persistence.swift │ ├── Model.swift │ └── ViewController.swift └── iOS-Example.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── xcshareddata │ └── xcschemes │ └── iOS Example.xcscheme ├── FetchRequests.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── FetchRequests-iOS.xcscheme │ ├── FetchRequests-visionOS.xcscheme │ ├── FetchRequests-tvOS.xcscheme │ ├── FetchRequests-macOS.xcscheme │ └── FetchRequests-watchOS.xcscheme ├── FetchRequests ├── FetchRequests.h ├── Tests │ ├── Models │ │ └── LoggingTestCase.swift │ ├── TestObject+FetchableObject.swift │ ├── SwiftUI │ │ └── FetchableRequestTestCase.swift │ ├── Controllers │ │ ├── FetchedResultsControllerTestHarness.swift │ │ ├── PausableFetchedResultsControllerTestCase.swift │ │ └── PaginatingFetchedResultsControllerTestCase.swift │ ├── TestObject+FetchRequests.swift │ ├── TestObject.swift │ └── TestObject+Associations.swift ├── Sources │ ├── IndexPath+Convenience.swift │ ├── CollectionType+SortDescriptors.swift │ ├── Associations │ │ ├── FetchableEntityID.swift │ │ ├── AssociatedValueReference.swift │ │ └── ObservableToken.swift │ ├── FetchableObject.swift │ ├── Requests │ │ ├── FetchDefinition.swift │ │ └── PaginatingFetchDefinition.swift │ ├── Logging.swift │ ├── SwiftUI │ │ └── FetchableRequest.swift │ ├── OrderedSetCompat.swift │ └── Controller │ │ ├── FetchedResultsControllerProtocol.swift │ │ ├── PausableFetchedResultsController.swift │ │ └── CollapsibleSectionsFetchedResultsController.swift ├── TestsInfo.plist └── Info.plist ├── LICENSE ├── Package.swift ├── Package@swift-5.9.swift ├── FetchRequests.podspec ├── .swiftformat ├── .gitignore ├── .github └── workflows │ └── build.yml ├── .swiftlint.yml ├── README.md └── CHANGELOG.md /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | ignore: 3 | - "Example" 4 | -------------------------------------------------------------------------------- /Example/iOS-Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/iOS-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/iOS-Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FetchRequests/FetchRequests.h: -------------------------------------------------------------------------------- 1 | // 2 | // FetchRequests.h 3 | // FetchRequests 4 | // 5 | // Created by Adam Lickel on 6/28/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for FetchRequests. 12 | FOUNDATION_EXPORT double FetchRequestsVersionNumber; 13 | 14 | //! Project version string for FetchRequests. 15 | FOUNDATION_EXPORT const unsigned char FetchRequestsVersionString[]; 16 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-collections", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-collections.git", 8 | "state" : { 9 | "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", 10 | "version" : "1.1.3" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Example/iOS-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-collections", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-collections.git", 8 | "state" : { 9 | "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", 10 | "version" : "1.1.3" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /FetchRequests/Tests/Models/LoggingTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggingTestCase.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 9/21/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FetchRequests 11 | 12 | class LoggingTestCase: XCTestCase { 13 | func testLogging() { 14 | CWLogVerbose("Verbose") 15 | CWLogDebug("Debug") 16 | CWLogInfo("Info") 17 | CWLogWarning("Warning") 18 | CWLogError("Error") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FetchRequests/Sources/IndexPath+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexPath+Convenience.swift 3 | // FetchRequests 4 | // 5 | // Created by Adam Lickel on 7/1/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | #elseif canImport(AppKit) 14 | import AppKit 15 | #else 16 | extension IndexPath { 17 | var section: Int { self[0] } 18 | var item: Int { self[1] } 19 | 20 | init(item: Int, section: Int) { 21 | self.init(indexes: [section, item]) 22 | } 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Example/iOS-Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 7/2/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | var window: UIWindow? 14 | 15 | func application( 16 | _ application: UIApplication, 17 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 18 | ) -> Bool { 19 | window = UIWindow(frame: UIScreen.main.bounds) 20 | window?.rootViewController = UINavigationController( 21 | rootViewController: ViewController() 22 | ) 23 | window?.makeKeyAndVisible() 24 | 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /FetchRequests/TestsInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /FetchRequests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Example/iOS-Example/Atomic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Atomic.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 9/22/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @propertyWrapper 12 | struct Atomic { 13 | private let queue = DispatchQueue(label: "Atomic Queue", attributes: .concurrent) 14 | private var storage: Value 15 | 16 | init(wrappedValue: Value) { 17 | self.storage = wrappedValue 18 | } 19 | 20 | var wrappedValue: Value { 21 | get { 22 | queue.sync { storage } 23 | } 24 | set { 25 | queue.sync(flags: .barrier) { storage = newValue } 26 | } 27 | } 28 | 29 | mutating func mutate(_ mutation: (inout Value) throws -> Void) rethrows { 30 | try queue.sync(flags: .barrier) { 31 | try mutation(&storage) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FetchRequests/Sources/CollectionType+SortDescriptors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionType+SortDescriptors.swift 3 | // Crew 4 | // 5 | // Created by Adam Lickel on 7/7/16. 6 | // Copyright © 2016 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Sequence where Element: NSSortDescriptor { 12 | var comparator: Comparator { 13 | { lhs, rhs in 14 | for sort in self { 15 | let result = sort.compare(lhs, to: rhs) 16 | guard result == .orderedSame else { 17 | return result 18 | } 19 | } 20 | return .orderedSame 21 | } 22 | } 23 | } 24 | 25 | public extension Sequence where Element: NSObject { 26 | func sorted(by descriptors: [NSSortDescriptor]) -> [Element] { 27 | guard !descriptors.isEmpty else { 28 | return Array(self) 29 | } 30 | 31 | return sorted(by: descriptors.comparator) 32 | } 33 | 34 | private func sorted(by comparator: Comparator) -> [Element] { 35 | sorted { comparator($0, $1) == .orderedAscending } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Square Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FetchRequests", 7 | platforms: [ 8 | .macCatalyst(.v14), 9 | .iOS(.v14), 10 | .tvOS(.v14), 11 | .watchOS(.v7), 12 | .macOS(.v11), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library( 17 | name: "FetchRequests", 18 | targets: ["FetchRequests"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "FetchRequests", 27 | dependencies: [ 28 | .product(name: "Collections", package: "swift-collections"), 29 | ], 30 | path: "FetchRequests", 31 | exclude: ["Tests", "Info.plist", "TestsInfo.plist"] 32 | ), 33 | .testTarget( 34 | name: "FetchRequestsTests", 35 | dependencies: ["FetchRequests"], 36 | path: "FetchRequests/Tests" 37 | ), 38 | ], 39 | swiftLanguageModes: [.v6] 40 | ) 41 | -------------------------------------------------------------------------------- /Package@swift-5.9.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FetchRequests", 7 | platforms: [ 8 | .macCatalyst(.v14), 9 | .iOS(.v14), 10 | .tvOS(.v14), 11 | .watchOS(.v7), 12 | .macOS(.v11), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library( 17 | name: "FetchRequests", 18 | targets: ["FetchRequests"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "FetchRequests", 27 | dependencies: [ 28 | .product(name: "Collections", package: "swift-collections"), 29 | ], 30 | path: "FetchRequests", 31 | exclude: ["Tests", "Info.plist", "TestsInfo.plist"] 32 | ), 33 | .testTarget( 34 | name: "FetchRequestsTests", 35 | dependencies: ["FetchRequests"], 36 | path: "FetchRequests/Tests" 37 | ), 38 | ], 39 | swiftLanguageVersions: [.v5] 40 | ) 41 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Associations/FetchableEntityID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchableEntityID.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 2/28/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol FetchableEntityID: Hashable, Sendable { 12 | associatedtype FetchableEntity: FetchableObject 13 | 14 | init?(from entity: FetchableEntity) 15 | 16 | static func fetch(byID objectID: Self) -> FetchableEntity? 17 | static func fetch(byIDs objectIDs: [Self]) -> [FetchableEntity] 18 | 19 | static func fetch( 20 | byID objectID: Self, 21 | completion: @escaping @Sendable @MainActor (FetchableEntity?) -> Void 22 | ) 23 | static func fetch( 24 | byIDs objectIDs: [Self], 25 | completion: @escaping @Sendable @MainActor ([FetchableEntity]) -> Void 26 | ) 27 | } 28 | 29 | extension FetchableEntityID { 30 | static func fetch(byID objectID: Self) -> FetchableEntity? { 31 | self.fetch(byIDs: [objectID]).first 32 | } 33 | 34 | static func fetch( 35 | byID objectID: Self, 36 | completion: @escaping @Sendable @MainActor (FetchableEntity?) -> Void 37 | ) { 38 | self.fetch(byIDs: [objectID]) { objects in 39 | completion(objects.first) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /FetchRequests.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'FetchRequests' 3 | s.version = '7.0.1' 4 | s.license = 'MIT' 5 | s.summary = 'NSFetchedResultsController inspired eventing' 6 | s.homepage = 'https://github.com/square/FetchRequests' 7 | s.authors = 'Square' 8 | s.source = { :git => 'https://github.com/square/FetchRequests.git', :tag => s.version } 9 | 10 | ios_deployment_target = '14.0' 11 | tvos_deployment_target = '14.0' 12 | watchos_deployment_target = '7.0' 13 | macos_deployment_target = '11' 14 | visionos_deployment_target = '1' 15 | 16 | s.ios.deployment_target = ios_deployment_target 17 | s.tvos.deployment_target = tvos_deployment_target 18 | s.watchos.deployment_target = watchos_deployment_target 19 | s.macos.deployment_target = macos_deployment_target 20 | s.visionos.deployment_target = visionos_deployment_target 21 | 22 | #s.swift_versions = ['5.0', '6.0'] 23 | s.swift_version = '5.0' 24 | 25 | s.source_files = [ 26 | 'FetchRequests/simplediff-swift/simplediff.swift', 27 | 'FetchRequests/Sources/**/*.swift', 28 | ] 29 | 30 | s.test_spec do |test_spec| 31 | test_spec.source_files = 'FetchRequests/Tests/**/*.swift' 32 | 33 | test_spec.ios.deployment_target = ios_deployment_target 34 | test_spec.tvos.deployment_target = tvos_deployment_target 35 | test_spec.watchos.deployment_target = watchos_deployment_target 36 | test_spec.macos.deployment_target = macos_deployment_target 37 | test_spec.visionos.deployment_target = visionos_deployment_target 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /Example/iOS-Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 6.0 2 | 3 | --exclude Pods 4 | 5 | --rules andOperator 6 | --rules anyObjectProtocol 7 | 8 | --rules blankLinesBetweenScopes 9 | --rules blankLineAfterImports 10 | --rules braces 11 | --rules conditionalAssignment 12 | --rules consecutiveSpaces 13 | --rules consistentSwitchCaseSpacing 14 | --rules duplicateImports 15 | --rules elseOnSameLine 16 | --rules genericExtensions 17 | 18 | --rules hoistAwait 19 | --rules hoistTry 20 | 21 | --rules indent 22 | --indent 4 23 | --indentcase false 24 | --indentstrings true 25 | --ifdef outdent 26 | 27 | --rules leadingDelimiters 28 | --rules linebreakAtEndOfFile 29 | 30 | --rules redundantBreak 31 | --rules redundantClosure 32 | --rules redundantFileprivate 33 | --rules redundantGet 34 | --rules redundantInit 35 | --rules redundantLet 36 | --rules redundantLetError 37 | --rules redundantOptionalBinding 38 | --rules redundantParens 39 | --rules redundantPattern 40 | --rules redundantReturn 41 | --rules redundantTypedThrows 42 | --rules redundantVoidReturnType 43 | --rules semicolons 44 | 45 | --rules spaceAroundBraces 46 | --rules spaceAroundBrackets 47 | --rules spaceAroundGenerics 48 | --rules spaceAroundOperators 49 | --rules spaceAroundParens 50 | 51 | --rules spaceInsideBraces 52 | --rules spaceInsideBrackets 53 | --rules spaceInsideComments 54 | --rules spaceInsideGenerics 55 | --rules spaceInsideParens 56 | 57 | --rules strongifiedSelf 58 | --rules todos 59 | --rules trailingClosures 60 | --rules trailingCommas 61 | --rules trailingSpace 62 | --rules typeSugar 63 | --rules void 64 | 65 | --rules wrapArguments 66 | --wraparguments before-first 67 | --wrapcollections before-first 68 | --wrapparameters before-first 69 | --closingparen balanced 70 | 71 | --rules wrapMultilineStatementBraces 72 | -------------------------------------------------------------------------------- /Example/iOS-Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | Package.resolved 41 | .build/ 42 | .swiftpm/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | # Pods/ 51 | 52 | # Carthage 53 | # 54 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 55 | # Carthage/Checkouts 56 | 57 | Carthage/Build 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 62 | # screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots/**/*.png 69 | fastlane/test_output 70 | -------------------------------------------------------------------------------- /FetchRequests/Sources/FetchableObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchableObject.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 3/14/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A class of types that should be fetchable via FetchRequests 12 | public typealias FetchableObject = NSObject & FetchableObjectProtocol 13 | 14 | /// A class of types whose instances hold raw data of that entity 15 | public protocol RawDataRepresentable { 16 | associatedtype RawData 17 | 18 | /// Initialize a fetchable object from raw data 19 | init?(data: RawData) 20 | 21 | /// The underlying data of the entity associated with `self`. 22 | var data: RawData { get } 23 | } 24 | 25 | /// A class of types that should be fetchable via FetchRequests 26 | public protocol FetchableObjectProtocol: NSObjectProtocol, Identifiable, RawDataRepresentable, Sendable where ID: Sendable { 27 | /// Has this object been marked as deleted? 28 | var isDeleted: Bool { get } 29 | 30 | /// Parse raw data to return the expected entity ID 31 | /// 32 | /// - parameter data: Raw data that potentially represents a FetchableObject 33 | /// - returns: The entityID for a fetchable object 34 | static func entityID(from data: RawData) -> ID? 35 | 36 | /// Listen for changes to the underlying data of `self` 37 | func observeDataChanges(_ handler: @escaping @Sendable @MainActor () -> Void) -> InvalidatableToken 38 | 39 | /// Listen for changes to whether `self` is deleted 40 | func observeIsDeletedChanges(_ handler: @escaping @Sendable @MainActor () -> Void) -> InvalidatableToken 41 | 42 | /// Enforce listening for changes to the underlying data of `self` 43 | func listenForUpdates() 44 | } 45 | 46 | extension FetchableObjectProtocol { 47 | public func listenForUpdates() {} 48 | } 49 | -------------------------------------------------------------------------------- /FetchRequests/Tests/TestObject+FetchableObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestObject+FetchableObject.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 3/29/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FetchRequests 11 | 12 | // MARK: - FetchableObjectProtocol 13 | 14 | extension TestObject: FetchableObjectProtocol { 15 | func observeDataChanges(_ handler: @escaping @Sendable @MainActor () -> Void) -> InvalidatableToken { 16 | self.observe(\.data, options: [.old, .new]) { object, change in 17 | guard let old = change.oldValue, let new = change.newValue else { 18 | return 19 | } 20 | let oldDict = old as NSDictionary 21 | let newDict = new as NSDictionary 22 | 23 | guard oldDict != newDict else { 24 | return 25 | } 26 | 27 | MainActor.assumeIsolated { 28 | handler() 29 | } 30 | } 31 | } 32 | 33 | func observeIsDeletedChanges(_ handler: @escaping @Sendable @MainActor () -> Void) -> InvalidatableToken { 34 | self.observe(\.isDeleted, options: [.old, .new]) { object, change in 35 | guard let old = change.oldValue, let new = change.newValue, old != new else { 36 | return 37 | } 38 | MainActor.assumeIsolated { 39 | handler() 40 | } 41 | } 42 | } 43 | 44 | static func entityID(from data: RawData) -> ID? { 45 | data["id"] as? String 46 | } 47 | } 48 | 49 | // MARK: - Event Notifications 50 | 51 | extension TestObject { 52 | static func objectWasCreated() -> Notification.Name { 53 | Notification.Name("TestObject.objectWasCreated") 54 | } 55 | 56 | static func dataWasCleared() -> Notification.Name { 57 | Notification.Name("TestObject.dataWasCleared") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: 8 | 9 | concurrency: 10 | group: ${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | platform: 18 | - iOS 19 | - tvOS 20 | - watchOS 21 | - macOS 22 | #- visionOS # These runtimes are missing? 23 | runs-on: macos-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Build 28 | uses: mxcl/xcodebuild@v3 29 | with: 30 | platform: ${{ matrix.platform }} 31 | scheme: FetchRequests-${{ matrix.platform }} 32 | #swift: ~6.0 33 | xcode: '16.0.0' # It's currently preferring 16.1 beta 34 | action: test 35 | code-coverage: true 36 | - name: Code Coverage 37 | #uses: codecov/codecov-action@v2 38 | run: bash <(curl -s https://codecov.io/bash); 39 | validate: 40 | runs-on: macos-latest 41 | needs: build 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Swift Lint 46 | run: | 47 | command -v swiftlint || brew install --quiet swiftlint 48 | swiftlint --reporter github-actions-logging --strict 49 | - name: Swift Format 50 | run: | 51 | command -v swiftformat || brew install --quiet swiftformat 52 | swiftformat --reporter github-actions-log --lint . 53 | - name: Pod Lint 54 | run: pod lib lint --quick --fail-fast --verbose --skip-tests 55 | - name: Example Project 56 | uses: mxcl/xcodebuild@v3 57 | with: 58 | platform: iOS 59 | scheme: iOS Example 60 | #swift: ~6.0 61 | xcode: '16.0.0' # It's currently preferring 16.1 beta 62 | action: build 63 | working-directory: Example 64 | -------------------------------------------------------------------------------- /FetchRequests/Tests/SwiftUI/FetchableRequestTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchableRequestTestCase.swift 3 | // FetchableRequestTestCase 4 | // 5 | // Created by Adam Lickel on 7/22/21. 6 | // Copyright © 2021 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import FetchRequests 12 | 13 | class FetchableRequestTestCase: XCTestCase { 14 | } 15 | 16 | extension FetchableRequestTestCase { 17 | private func createFetchDefinition(ids: [String] = ["a", "b", "c"]) -> FetchDefinition { 18 | let request: FetchDefinition.Request = { completion in 19 | completion(ids.map { TestObject(id: $0, sectionName: $0) }) 20 | } 21 | 22 | return FetchDefinition(request: request) 23 | } 24 | 25 | @MainActor 26 | func testCreation() { 27 | var instance = FetchableRequest( 28 | definition: createFetchDefinition(), 29 | debounceInsertsAndReloads: false 30 | ) 31 | 32 | XCTAssertFalse(instance.hasFetchedObjects) 33 | 34 | let results = instance.wrappedValue 35 | XCTAssertEqual(results.map(\.id), []) 36 | 37 | // Note: This will trigger a runtime warning about a static binding 38 | instance.update() 39 | 40 | XCTAssertTrue(instance.hasFetchedObjects) 41 | } 42 | 43 | @MainActor 44 | func testSectionedCreation() { 45 | var instance = SectionedFetchableRequest( 46 | definition: createFetchDefinition(), 47 | sectionNameKeyPath: \TestObject.sectionName, 48 | debounceInsertsAndReloads: false 49 | ) 50 | instance.update() 51 | 52 | let results = instance.wrappedValue 53 | XCTAssertEqual(results[0].objects[0].id, "a") 54 | XCTAssertEqual(results.map(\.id), ["a", "b", "c"]) 55 | XCTAssertEqual(results.map(\.name), ["a", "b", "c"]) 56 | XCTAssertEqual(results.map(\.numberOfObjects), [1, 1, 1]) 57 | XCTAssertEqual(results.flatMap(\.objects).map(\.id), ["a", "b", "c"]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Example/iOS-Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Example/iOS-Example/ObservableValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableValue.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 9/22/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FetchRequests 12 | 13 | struct Change { 14 | var oldValue: Value 15 | var newValue: Value 16 | } 17 | 18 | @propertyWrapper 19 | class ObservableValue { 20 | typealias Handler = @MainActor (Change) -> Void 21 | 22 | fileprivate var observers: Atomic<[UUID: Handler]> = Atomic(wrappedValue: [:]) 23 | 24 | var wrappedValue: Value { 25 | didSet { 26 | MainActor.assumeIsolated { 27 | let change = Change(oldValue: oldValue, newValue: wrappedValue) 28 | let observers = self.observers.wrappedValue 29 | 30 | observers.values.forEach { $0(change) } 31 | } 32 | } 33 | } 34 | 35 | init(wrappedValue: Value) { 36 | self.wrappedValue = wrappedValue 37 | } 38 | 39 | func observe(handler: @escaping Handler) -> InvalidatableToken { 40 | let token = Token(parent: self) 41 | observers.mutate { value in 42 | value[token.uuid] = handler 43 | } 44 | return token 45 | } 46 | } 47 | 48 | extension ObservableValue where Value: Equatable { 49 | func observeChanges(handler: @escaping Handler) -> InvalidatableToken { 50 | observe { change in 51 | guard change.oldValue != change.newValue else { 52 | return 53 | } 54 | handler(change) 55 | } 56 | } 57 | } 58 | 59 | private class Token: InvalidatableToken { 60 | let uuid = UUID() 61 | private weak var parent: ObservableValue? 62 | 63 | init(parent: ObservableValue) { 64 | self.parent = parent 65 | } 66 | 67 | func invalidate() { 68 | parent?.observers.mutate { value in 69 | value[uuid] = nil 70 | } 71 | parent = nil 72 | } 73 | 74 | deinit { 75 | invalidate() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Requests/FetchDefinition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchDefinition.swift 3 | // Crew 4 | // 5 | // Created by Adam Lickel on 7/7/16. 6 | // Copyright © 2016 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class FetchDefinition { 12 | public typealias Request = @MainActor (_ completion: @escaping ([FetchedObject]) -> Void) -> Void 13 | public typealias CreationInclusionCheck = @MainActor (_ rawData: FetchedObject.RawData) -> Bool 14 | 15 | internal let request: Request 16 | internal let objectCreationToken: FetchRequestObservableToken 17 | internal let creationInclusionCheck: CreationInclusionCheck 18 | internal let associations: [FetchRequestAssociation] 19 | internal let dataResetTokens: [FetchRequestObservableToken] 20 | 21 | internal let associationsByKeyPath: [FetchRequestAssociation.AssociationKeyPath: FetchRequestAssociation] 22 | 23 | public init< 24 | VoidToken: ObservableToken, 25 | DataToken: ObservableToken 26 | >( 27 | request: @escaping Request, 28 | objectCreationToken: DataToken, 29 | creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, 30 | associations: [FetchRequestAssociation] = [], 31 | dataResetTokens: [VoidToken] = [] 32 | ) { 33 | self.request = request 34 | self.objectCreationToken = FetchRequestObservableToken(token: objectCreationToken) 35 | self.creationInclusionCheck = creationInclusionCheck 36 | self.associations = associations 37 | self.dataResetTokens = dataResetTokens.map { FetchRequestObservableToken(token: $0) } 38 | 39 | associationsByKeyPath = associations.reduce(into: [:]) { memo, element in 40 | assert(element.keyPath._kvcKeyPathString != nil, "\(element.keyPath) is not KVC compliant?") 41 | assert(memo[element.keyPath] == nil, "You cannot reuse \(element.keyPath) for two associations") 42 | memo[element.keyPath] = element 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/iOS-Example/Model+Fetchable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model+Fetchable.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 7/2/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FetchRequests 12 | 13 | // MARK: - Fetch Requests 14 | 15 | extension FetchableObjectProtocol where Self: Model { 16 | static func fetchDefinition() -> FetchDefinition { 17 | let dataResetTokens: [ModelClearedToken] = [ 18 | ModelClearedToken(), 19 | ] 20 | 21 | return FetchDefinition( 22 | request: { completion in 23 | completion(fetchAll()) 24 | }, 25 | objectCreationToken: ModelCreationToken(), 26 | dataResetTokens: dataResetTokens 27 | ) 28 | } 29 | } 30 | 31 | class ModelCreationToken: ObservableToken { 32 | let notificationToken: ObservableNotificationCenterToken 33 | let include: (T) -> Bool 34 | 35 | init(name: Notification.Name = T.objectWasCreated(), include: @escaping (T) -> Bool = { _ in true }) { 36 | notificationToken = ObservableNotificationCenterToken(name: name) 37 | self.include = include 38 | } 39 | 40 | func invalidate() { 41 | notificationToken.invalidate() 42 | } 43 | 44 | func observe(handler: @escaping @Sendable @MainActor (T.RawData) -> Void) { 45 | let include = self.include 46 | notificationToken.observe { notification in 47 | guard let object = notification.object as? T else { 48 | return 49 | } 50 | guard include(object) else { 51 | return 52 | } 53 | handler(object.data) 54 | } 55 | } 56 | } 57 | 58 | class ModelClearedToken: ObservableToken { 59 | let notificationToken: ObservableNotificationCenterToken 60 | 61 | init() { 62 | notificationToken = ObservableNotificationCenterToken(name: T.dataWasCleared()) 63 | } 64 | 65 | func invalidate() { 66 | notificationToken.invalidate() 67 | } 68 | 69 | func observe(handler: @escaping @Sendable @MainActor (()) -> Void) { 70 | notificationToken.observe { notification in 71 | handler(()) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /FetchRequests/Tests/Controllers/FetchedResultsControllerTestHarness.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchedResultsControllerTestHarness.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 9/27/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Foundation 11 | @testable import FetchRequests 12 | 13 | protocol FetchedResultsControllerTestHarness { 14 | associatedtype FetchController: FetchedResultsControllerProtocol where 15 | FetchController.FetchedObject == TestObject 16 | 17 | // swiftlint:disable implicitly_unwrapped_optional 18 | 19 | var controller: FetchController! { get } 20 | var fetchCompletion: (([TestObject]) -> Void)! { get } 21 | 22 | // swiftlint:enable implicitly_unwrapped_optional 23 | } 24 | 25 | extension FetchedResultsControllerTestHarness { 26 | @MainActor 27 | func performFetch(_ objectIDs: [String], file: StaticString = #filePath, line: UInt = #line) throws { 28 | let objects = objectIDs.compactMap { TestObject(id: $0) } 29 | 30 | try performFetch(objects, file: file, line: line) 31 | } 32 | 33 | @MainActor 34 | func performFetch(_ objects: [TestObject], file: StaticString = #filePath, line: UInt = #line) throws { 35 | controller.performFetch() 36 | 37 | self.fetchCompletion(objects) 38 | 39 | let sortedObjects = objects.sorted(by: controller.sortDescriptors) 40 | XCTAssertEqual(sortedObjects, controller.fetchedObjects, file: file, line: line) 41 | } 42 | 43 | // swiftlint:disable:next implicitly_unwrapped_optional 44 | func getObjectAtIndex(_ index: Int, withObjectID objectID: String, file: StaticString = #filePath, line: UInt = #line) -> TestObject! { 45 | let object = controller.fetchedObjects[index] 46 | 47 | XCTAssertEqual(object.id, objectID, file: file, line: line) 48 | 49 | return object 50 | } 51 | } 52 | 53 | extension FetchedResultsController where FetchedObject: TestObject { 54 | var fetchedIDs: [String] { 55 | fetchedObjects.map(\.id) 56 | } 57 | 58 | var tags: [Int] { 59 | fetchedObjects.map(\.tag) 60 | } 61 | } 62 | 63 | extension FetchedResultsSection where FetchedObject: TestObject { 64 | var fetchedIDs: [String] { 65 | objects.map(\.id) 66 | } 67 | 68 | var tags: [Int] { 69 | objects.map(\.tag) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /FetchRequests/Tests/TestObject+FetchRequests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestObject+FetchRequests.swift 3 | // FetchRequests 4 | // 5 | // Created by Adam Lickel on 9/16/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FetchRequests 11 | 12 | extension FetchDefinition where FetchedObject: TestObject { 13 | convenience init( 14 | request: @escaping Request, 15 | objectCreationNotification: Notification.Name? = nil, 16 | creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, 17 | associations: [FetchRequestAssociation] = [] 18 | ) { 19 | let objectCreationNotification = objectCreationNotification ?? FetchedObject.objectWasCreated() 20 | 21 | let dataResetNotifications = [ 22 | FetchedObject.dataWasCleared(), 23 | ] 24 | 25 | self.init( 26 | request: request, 27 | objectCreationToken: TestEntityObservableToken(name: objectCreationNotification), 28 | creationInclusionCheck: creationInclusionCheck, 29 | associations: associations, 30 | dataResetTokens: dataResetNotifications.map { 31 | VoidNotificationObservableToken(name: $0) 32 | } 33 | ) 34 | } 35 | } 36 | 37 | extension PaginatingFetchDefinition where FetchedObject: TestObject { 38 | convenience init( 39 | request: @escaping Request, 40 | paginationRequest: @escaping PaginationRequest, 41 | objectCreationNotification: Notification.Name? = nil, 42 | creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, 43 | associations: [FetchRequestAssociation] = [] 44 | ) { 45 | let objectCreationNotification = objectCreationNotification ?? FetchedObject.objectWasCreated() 46 | 47 | let dataResetNotifications = [ 48 | FetchedObject.dataWasCleared(), 49 | ] 50 | 51 | self.init( 52 | request: request, 53 | paginationRequest: paginationRequest, 54 | objectCreationToken: TestEntityObservableToken(name: objectCreationNotification), 55 | creationInclusionCheck: creationInclusionCheck, 56 | associations: associations, 57 | dataResetTokens: dataResetNotifications.map { 58 | VoidNotificationObservableToken(name: $0) 59 | } 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /FetchRequests/Tests/TestObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestObject.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 2/25/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FetchRequests 11 | 12 | final class TestObject: NSObject, @unchecked Sendable { 13 | typealias RawData = [String: Any] 14 | 15 | @objc dynamic var id: String 16 | @objc dynamic var tag: Int = 0 17 | @objc dynamic var sectionName: String = "" 18 | @objc dynamic var data: RawData = RawData() { 19 | didSet { 20 | integrate(data: data) 21 | } 22 | } 23 | 24 | @objc dynamic var isDeleted: Bool = false 25 | 26 | // MARK: NSObject Overrides 27 | 28 | override func isEqual(_ object: Any?) -> Bool { 29 | guard let other = object as? TestObject else { 30 | return false 31 | } 32 | 33 | return id == other.id 34 | } 35 | 36 | override var hash: Int { 37 | var hasher = Hasher() 38 | hasher.combine(id) 39 | 40 | return hasher.finalize() 41 | } 42 | 43 | // MARK: - Initialization & Integration 44 | 45 | required init?(data: RawData) { 46 | guard let id = TestObject.entityID(from: data) else { 47 | return nil 48 | } 49 | self.id = id 50 | super.init() 51 | self.data = data 52 | integrate(data: data) 53 | } 54 | 55 | init(id: String, tag: Int = 0, sectionName: String = "") { 56 | self.id = id 57 | super.init() 58 | data = [ 59 | "id": id, 60 | "tag": tag, 61 | "sectionName": sectionName, 62 | ] 63 | integrate(data: data) 64 | } 65 | 66 | private func integrate(data: RawData) { 67 | tag = (data["tag"] as? Int) ?? 0 68 | sectionName = (data["sectionName"] as? String) ?? "" 69 | } 70 | } 71 | 72 | // MARK: - KVO-able synthetic properties 73 | 74 | extension TestObject { 75 | @objc 76 | dynamic var tagID: String? { 77 | String(tag) 78 | } 79 | 80 | @objc 81 | dynamic var tagIDs: [String]? { 82 | tagID.map { [$0] } 83 | } 84 | 85 | @objc 86 | class func keyPathsForValuesAffectingTagID() -> Set { 87 | [#keyPath(tag)] 88 | } 89 | 90 | @objc 91 | class func keyPathsForValuesAffectingTagIDs() -> Set { 92 | [#keyPath(tag)] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Example/iOS-Example/SwiftUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // iOS Example 4 | // 5 | // Created by Ansel Merino on 5/31/23. 6 | // Copyright © 2023 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | import FetchRequests 13 | 14 | struct SwiftUIView: View { 15 | @FetchableRequest( 16 | definition: Model.fetchDefinition(), 17 | sortDescriptors: [ 18 | NSSortDescriptor( 19 | key: #keyPath(Model.updatedAt), 20 | ascending: false 21 | ), 22 | ], 23 | animation: Animation.easeIn(duration: 1.0) 24 | ) 25 | private var models: FetchableResults 26 | 27 | var body: some View { 28 | NavigationView { 29 | List(models) { model in 30 | row(for: model) 31 | } 32 | .listStyle(.plain) 33 | .transition(.slide) 34 | .navigationBarTitle("SwiftUI Example", displayMode: .inline) 35 | .navigationBarItems( 36 | leading: Button { 37 | Model.reset() 38 | } label: { 39 | Image(systemName: "trash") 40 | }, 41 | trailing: Button { 42 | try? Model().save() 43 | } label: { 44 | Image(systemName: "plus") 45 | } 46 | ) 47 | } 48 | } 49 | } 50 | 51 | // MARK: List Row 52 | extension SwiftUIView { 53 | @ViewBuilder 54 | func row(for model: Model) -> some View { 55 | if #available(iOS 15.0, *) { 56 | VStack(alignment: .leading) { 57 | Text(model.id) 58 | .font(Font.system(.body)) 59 | .scaledToFit() 60 | .minimumScaleFactor(0.5) 61 | .lineLimit(1) 62 | Text(model.createdAt.description) 63 | .font(Font.system(.footnote)) 64 | .lineLimit(1) 65 | } 66 | .swipeActions { 67 | Button("Delete") { 68 | try? model.delete() 69 | } 70 | .tint(.red) 71 | } 72 | .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) 73 | } else { 74 | VStack(alignment: .leading) { 75 | Text(model.id) 76 | .font(Font.system(.body)) 77 | .scaledToFit() 78 | .minimumScaleFactor(0.5) 79 | .lineLimit(1) 80 | Text(model.createdAt.description) 81 | .font(Font.system(.footnote)) 82 | .lineLimit(1) 83 | } 84 | .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - file_length 3 | - function_parameter_count 4 | - nesting 5 | - opening_brace 6 | - unused_closure_parameter 7 | - unused_optional_binding 8 | # Nice to haves 9 | - cyclomatic_complexity 10 | - function_body_length 11 | analyzer_rules: 12 | - capture_variable 13 | - unused_declaration 14 | - unused_import 15 | opt_in_rules: 16 | - balanced_xctest_lifecycle 17 | - closure_spacing 18 | - closure_end_indentation 19 | - collection_alignment 20 | - comment_spacing 21 | - conditional_returns_on_newline 22 | - contains_over_first_not_nil 23 | - contains_over_filter_count 24 | - contains_over_filter_is_empty 25 | - contains_over_range_nil_comparison 26 | - discarded_notification_center_observer 27 | - empty_count 28 | - empty_collection_literal 29 | - empty_string 30 | - empty_xctest_method 31 | - explicit_init 32 | - fatal_error_message 33 | - first_where 34 | - flatmap_over_map_reduce 35 | - identical_operands 36 | - implicitly_unwrapped_optional 37 | - joined_default_parameter 38 | - last_where 39 | - legacy_random 40 | - literal_expression_end_indentation 41 | - lower_acl_than_parent 42 | - modifier_order 43 | - multiline_arguments 44 | - multiline_parameters 45 | - multiline_arguments_brackets 46 | - multiline_literal_brackets 47 | - multiline_parameters_brackets 48 | - nslocalizedstring_key 49 | - number_separator 50 | - optional_enum_case_matching 51 | - overridden_super_call 52 | - pattern_matching_keywords 53 | - prefer_key_path 54 | - prefer_self_type_over_type_of_self 55 | - private_swiftui_state 56 | - prohibited_super_call 57 | - private_outlet 58 | - private_subject 59 | - private_unit_test 60 | - reduce_into 61 | - redundant_nil_coalescing 62 | - redundant_objc_attribute 63 | - self_binding 64 | - shorthand_argument 65 | - single_test_class 66 | - shorthand_optional_binding 67 | - sorted_first_last 68 | - static_operator 69 | - strong_iboutlet 70 | - test_case_accessibility 71 | - toggle_bool 72 | - trailing_closure 73 | - untyped_error_in_catch 74 | - vertical_parameter_alignment_on_call 75 | - vertical_whitespace_between_cases 76 | - vertical_whitespace_opening_braces 77 | - vertical_whitespace_closing_braces 78 | - xct_specific_matcher 79 | excluded: 80 | - Pods 81 | - .build 82 | 83 | type_name: 84 | excluded: 85 | - ID 86 | max_length: 87 | warning: 50 88 | error: 60 89 | modifier_order: 90 | preferred_modifier_order: [acl, override] 91 | identifier_name: 92 | excluded: 93 | - id 94 | - to 95 | trailing_comma: 96 | mandatory_comma: true 97 | trailing_whitespace: 98 | ignores_empty_lines: true 99 | trailing_closure: 100 | only_single_muted_parameter: true 101 | large_tuple: 102 | warning: 3 103 | error: 4 104 | line_length: 105 | warning: 200 106 | 107 | deployment_target: 108 | iOS_deployment_target: 14 109 | tvOS_deployment_target: 14 110 | watchOS_deployment_target: 7 111 | macOS_deployment_target: 11 112 | # visionOS_deployment_target: 1 113 | -------------------------------------------------------------------------------- /Example/iOS-Example.xcodeproj/xcshareddata/xcschemes/iOS Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 65 | 71 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-visionOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 53 | 54 | 55 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 78 | 79 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 10/1/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if canImport(CocoaLumberjack) 12 | import CocoaLumberjack 13 | 14 | func CWLogVerbose( 15 | _ message: @autoclosure () -> String, 16 | level: DDLogLevel = dynamicLogLevel, 17 | context: Int = 0, 18 | file: StaticString = #filePath, 19 | function: StaticString = #function, 20 | line: UInt = #line, 21 | tag: Any? = nil, 22 | asynchronous async: Bool = true, 23 | ddlog: DDLog = DDLog.sharedInstance 24 | ) { 25 | DDLogVerbose( 26 | message(), 27 | level: level, 28 | context: context, 29 | file: file, 30 | function: function, 31 | line: line, 32 | tag: tag, 33 | asynchronous: async, 34 | ddlog: ddlog 35 | ) 36 | } 37 | 38 | func CWLogDebug( 39 | _ message: @autoclosure () -> String, 40 | level: DDLogLevel = dynamicLogLevel, 41 | context: Int = 0, 42 | file: StaticString = #filePath, 43 | function: StaticString = #function, 44 | line: UInt = #line, 45 | tag: Any? = nil, 46 | asynchronous async: Bool = true, 47 | ddlog: DDLog = DDLog.sharedInstance 48 | ) { 49 | DDLogDebug( 50 | message(), 51 | level: level, 52 | context: context, 53 | file: file, 54 | function: function, 55 | line: line, 56 | tag: tag, 57 | asynchronous: async, 58 | ddlog: ddlog 59 | ) 60 | } 61 | 62 | func CWLogInfo( 63 | _ message: @autoclosure () -> String, 64 | level: DDLogLevel = dynamicLogLevel, 65 | context: Int = 0, 66 | file: StaticString = #filePath, 67 | function: StaticString = #function, 68 | line: UInt = #line, 69 | tag: Any? = nil, 70 | asynchronous async: Bool = true, 71 | ddlog: DDLog = DDLog.sharedInstance 72 | ) { 73 | DDLogInfo( 74 | message(), 75 | level: level, 76 | context: context, 77 | file: file, 78 | function: function, 79 | line: line, 80 | tag: tag, 81 | asynchronous: async, 82 | ddlog: ddlog 83 | ) 84 | } 85 | 86 | func CWLogWarning( 87 | _ message: @autoclosure () -> String, 88 | level: DDLogLevel = dynamicLogLevel, 89 | context: Int = 0, 90 | file: StaticString = #filePath, 91 | function: StaticString = #function, 92 | line: UInt = #line, 93 | tag: Any? = nil, 94 | asynchronous async: Bool = true, 95 | ddlog: DDLog = DDLog.sharedInstance 96 | ) { 97 | DDLogWarn( 98 | message(), 99 | level: level, 100 | context: context, 101 | file: file, 102 | function: function, 103 | line: line, 104 | tag: tag, 105 | asynchronous: async, 106 | ddlog: ddlog 107 | ) 108 | } 109 | 110 | func CWLogError( 111 | _ message: @autoclosure () -> String, 112 | level: DDLogLevel = dynamicLogLevel, 113 | context: Int = 0, 114 | file: StaticString = #filePath, 115 | function: StaticString = #function, 116 | line: UInt = #line, 117 | tag: Any? = nil, 118 | asynchronous async: Bool = false, 119 | ddlog: DDLog = DDLog.sharedInstance 120 | ) { 121 | DDLogError( 122 | message(), 123 | level: level, 124 | context: context, 125 | file: file, 126 | function: function, 127 | line: line, 128 | tag: tag, 129 | asynchronous: async, 130 | ddlog: ddlog 131 | ) 132 | } 133 | #else 134 | func CWLogDebug(_ message: @autoclosure () -> String) { 135 | #if DEBUG 136 | NSLog(message()) 137 | #endif 138 | } 139 | 140 | func CWLogInfo(_ message: @autoclosure () -> String) { 141 | #if DEBUG 142 | NSLog(message()) 143 | #endif 144 | } 145 | 146 | func CWLogWarning(_ message: @autoclosure () -> String) { 147 | #if DEBUG 148 | NSLog(message()) 149 | #endif 150 | } 151 | 152 | func CWLogVerbose(_ message: @autoclosure () -> String) { 153 | #if DEBUG 154 | NSLog(message()) 155 | #endif 156 | } 157 | 158 | func CWLogError(_ message: @autoclosure () -> String) { 159 | #if DEBUG 160 | NSLog(message()) 161 | #endif 162 | } 163 | #endif 164 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 53 | 54 | 55 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-macOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 53 | 54 | 55 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /FetchRequests.xcodeproj/xcshareddata/xcschemes/FetchRequests-watchOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 53 | 54 | 55 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /Example/iOS-Example/Model+Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model+Persistence.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 7/2/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FetchRequests 12 | 13 | enum ModelError: Error { 14 | case invalidDate 15 | } 16 | 17 | private let encoder = JSONEncoder() 18 | private let decoder = JSONDecoder() 19 | 20 | // MARK: - Event Notifications 21 | 22 | extension Model { 23 | func rawObjectEventUpdated() -> Notification.Name { 24 | Notification.Name("\(NSStringFromClass(type(of: self))).rawObjectEventUpdated.\(id)") 25 | } 26 | 27 | class func objectWasCreated() -> Notification.Name { 28 | Notification.Name("\(NSStringFromClass(self)).objectWasCreated") 29 | } 30 | 31 | class func objectWasDeleted() -> Notification.Name { 32 | Notification.Name("\(NSStringFromClass(self)).objectWasDeleted") 33 | } 34 | 35 | class func dataWasCleared() -> Notification.Name { 36 | Notification.Name("\(NSStringFromClass(self)).dataWasCleared") 37 | } 38 | } 39 | 40 | // MARK: - Storage 41 | 42 | extension Model { 43 | fileprivate class var storage: [String: Any] { 44 | assert(Thread.isMainThread) 45 | 46 | let key = NSStringFromClass(self) 47 | return UserDefaults.standard.dictionary(forKey: key) ?? [:] 48 | } 49 | 50 | private class func updateStorage(_ block: (inout [String: Any]) throws -> Void) rethrows { 51 | assert(Thread.isMainThread) 52 | 53 | let defaults = UserDefaults.standard 54 | let key = NSStringFromClass(self) 55 | 56 | var storage = defaults.dictionary(forKey: key) ?? [:] 57 | try block(&storage) 58 | defaults.set(storage, forKey: key) 59 | } 60 | 61 | private class func validateCanUpdate(_ originalModel: Model) throws -> Model { 62 | var data = originalModel.data 63 | data.updatedAt = Date() 64 | 65 | let model = Model(data: data) 66 | 67 | guard model.createdAt != .distantPast else { 68 | throw ModelError.invalidDate 69 | } 70 | 71 | guard let existing = self.fetch(byID: model.id) else { 72 | // First instance 73 | return model 74 | } 75 | guard existing.updatedAt <= model.updatedAt, 76 | existing.createdAt == model.createdAt 77 | else { 78 | throw ModelError.invalidDate 79 | } 80 | 81 | // Newest instance 82 | return model 83 | } 84 | 85 | class func save(_ originalModel: Model) throws { 86 | let model = try validateCanUpdate(originalModel) 87 | 88 | try updateStorage { 89 | let data = try encoder.encode(model.data) 90 | $0[model.id] = data 91 | } 92 | 93 | NotificationCenter.default.post( 94 | name: model.rawObjectEventUpdated(), 95 | object: model, 96 | userInfo: ["data": model.data] 97 | ) 98 | 99 | NotificationCenter.default.post(name: objectWasCreated(), object: model) 100 | } 101 | 102 | class func delete(_ originalModel: Model) throws { 103 | let model = try validateCanUpdate(originalModel) 104 | 105 | updateStorage { 106 | $0[model.id] = nil 107 | } 108 | 109 | NotificationCenter.default.post( 110 | name: model.rawObjectEventUpdated(), 111 | object: model 112 | ) 113 | 114 | NotificationCenter.default.post(name: objectWasDeleted(), object: model) 115 | } 116 | 117 | class func reset() { 118 | updateStorage { 119 | $0.removeAll() 120 | } 121 | 122 | NotificationCenter.default.post(name: dataWasCleared(), object: nil) 123 | } 124 | } 125 | 126 | extension NSObjectProtocol where Self: Model { 127 | static func fetchAll() -> [Self] { 128 | storage.values.lazy.compactMap { value in 129 | value as? Data 130 | }.compactMap { data in 131 | try? decoder.decode(Model.RawData.self, from: data) 132 | }.map { 133 | Self(data: $0) 134 | } 135 | } 136 | 137 | static func fetch(byID id: Model.ID) -> Self? { 138 | storage[id].flatMap { value in 139 | value as? Data 140 | }.flatMap { data in 141 | try? decoder.decode(Model.RawData.self, from: data) 142 | }.map { 143 | Self(data: $0) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Associations/AssociatedValueReference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AssociatedValueReference.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 3/13/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - Internal Structures 12 | 13 | struct AssociatedValueKey: Hashable { 14 | var id: FetchedObject.ID 15 | var keyPath: FetchRequestAssociation.AssociationKeyPath 16 | } 17 | 18 | #if compiler(>=6) 19 | extension AssociatedValueKey: Sendable {} 20 | #else 21 | extension AssociatedValueKey: @unchecked Sendable {} 22 | #endif 23 | 24 | class FetchableAssociatedValueReference: AssociatedValueReference, @unchecked Sendable { 25 | private var observations: [Entity: [InvalidatableToken]] = [:] 26 | 27 | fileprivate override func stopObservingValue() { 28 | observations.values.forEach { $0.forEach { $0.invalidate() } } 29 | observations.removeAll() 30 | } 31 | 32 | fileprivate override func startObservingValue() { 33 | let entities: [Entity] = if let value = value as? Entity { 34 | [value] 35 | } else if let value = value as? [Entity] { 36 | value 37 | } else { 38 | [] 39 | } 40 | 41 | for entity in entities { 42 | observations[entity] = observeChanges(for: entity) 43 | } 44 | } 45 | 46 | private func observeChanges(for entity: Entity) -> [InvalidatableToken] { 47 | entity.listenForUpdates() 48 | 49 | let dataObserver = entity.observeDataChanges { [weak self] in 50 | self?.changeHandler?(false) 51 | } 52 | 53 | let isDeletedObserver = entity.observeIsDeletedChanges { [weak self, weak entity] in 54 | guard let entity else { 55 | return 56 | } 57 | self?.observedDeletionEvent(with: entity) 58 | } 59 | 60 | return [dataObserver, isDeletedObserver] 61 | } 62 | 63 | @MainActor 64 | private func observedDeletionEvent(with entity: Entity) { 65 | var invalidate = false 66 | if let value = value as? Entity, value == entity { 67 | observations.removeAll() 68 | self.value = nil 69 | } else if let value = self.value as? [Entity] { 70 | observations[entity] = nil 71 | self.value = value.filter { !($0 == entity) } 72 | } else { 73 | invalidate = true 74 | } 75 | changeHandler?(invalidate) 76 | } 77 | } 78 | 79 | class AssociatedValueReference: NSObject, @unchecked Sendable { 80 | typealias CreationObserved = @MainActor (_ value: Any?, _ entity: Any) -> AssociationReplacement 81 | typealias ChangeHandler = @MainActor (_ invalidate: Bool) -> Void 82 | 83 | private let creationObserver: FetchRequestObservableToken? 84 | private let creationObserved: CreationObserved 85 | 86 | fileprivate(set) var value: Any? 87 | fileprivate var changeHandler: ChangeHandler? 88 | 89 | var canObserveCreation: Bool { 90 | creationObserver != nil 91 | } 92 | 93 | init( 94 | creationObserver: FetchRequestObservableToken? = nil, 95 | creationObserved: @escaping CreationObserved = { _, _ in .same }, 96 | value: Any? = nil 97 | ) { 98 | self.creationObserver = creationObserver 99 | self.creationObserved = creationObserved 100 | self.value = value 101 | } 102 | 103 | deinit { 104 | stopObserving() 105 | } 106 | 107 | fileprivate func startObservingValue() { } 108 | 109 | fileprivate func stopObservingValue() { } 110 | } 111 | 112 | extension AssociatedValueReference { 113 | func stopObservingAndUpdateValue(to value: Any) { 114 | stopObserving() 115 | 116 | self.value = value 117 | } 118 | 119 | func observeChanges(_ changeHandler: @escaping ChangeHandler) { 120 | stopObserving() 121 | 122 | self.changeHandler = changeHandler 123 | 124 | startObservingValue() 125 | 126 | creationObserver?.observeIfNeeded { [weak self] entity in 127 | self?.observedCreationEvent(with: entity) 128 | } 129 | } 130 | 131 | func stopObserving() { 132 | guard changeHandler != nil else { 133 | return 134 | } 135 | 136 | stopObservingValue() 137 | 138 | creationObserver?.invalidateIfNeeded() 139 | 140 | changeHandler = nil 141 | } 142 | 143 | @MainActor 144 | private func observedCreationEvent(with entity: Any) { 145 | assert(Thread.isMainThread) 146 | 147 | // We just received a notification about an entity being created 148 | 149 | switch creationObserved(value, entity) { 150 | case .same: 151 | return 152 | 153 | case .invalid: 154 | changeHandler?(true) 155 | 156 | case let .changed(newValue): 157 | let currentChangeHandler = self.changeHandler 158 | 159 | stopObservingAndUpdateValue(to: newValue) 160 | 161 | if let currentChangeHandler { 162 | observeChanges(currentChangeHandler) 163 | currentChangeHandler(false) 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Requests/PaginatingFetchDefinition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaginatingFetchDefinition.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 2/28/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class PaginatingFetchDefinition: FetchDefinition { 12 | public typealias PaginationRequest = @MainActor ( 13 | _ currentResults: [FetchedObject], 14 | _ completion: @escaping ([FetchedObject]?) -> Void 15 | ) -> Void 16 | 17 | internal let paginationRequest: PaginationRequest 18 | 19 | public init< 20 | VoidToken: ObservableToken, 21 | DataToken: ObservableToken 22 | >( 23 | request: @escaping Request, 24 | paginationRequest: @escaping PaginationRequest, 25 | objectCreationToken: DataToken, 26 | creationInclusionCheck: @escaping CreationInclusionCheck = { _ in true }, 27 | associations: [FetchRequestAssociation] = [], 28 | dataResetTokens: [VoidToken] = [] 29 | ) { 30 | self.paginationRequest = paginationRequest 31 | super.init( 32 | request: request, 33 | objectCreationToken: objectCreationToken, 34 | creationInclusionCheck: creationInclusionCheck, 35 | associations: associations, 36 | dataResetTokens: dataResetTokens 37 | ) 38 | } 39 | } 40 | 41 | private extension InternalFetchResultsControllerProtocol { 42 | @MainActor 43 | func performPagination( 44 | with paginationRequest: PaginatingFetchDefinition.PaginationRequest, 45 | willDebounceInsertsAndReloads: Bool, 46 | completion: @escaping @MainActor (_ hasPageResults: Bool) -> Void 47 | ) { 48 | let currentResults = self.fetchedObjects 49 | paginationRequest(currentResults) { [weak self] pageResults in 50 | guard let pageResults else { 51 | completion(false) 52 | return 53 | } 54 | 55 | performOnMainThread { 56 | self?.manuallyInsert(objects: pageResults, emitChanges: true) 57 | } 58 | 59 | if willDebounceInsertsAndReloads { 60 | // force this to run on the _next_ run loop, at which point any debounced insertions should have happened 61 | DispatchQueue.main.async { 62 | completion(!pageResults.isEmpty) 63 | } 64 | } else { 65 | completion(!pageResults.isEmpty) 66 | } 67 | } 68 | } 69 | } 70 | 71 | public class PaginatingFetchedResultsController< 72 | FetchedObject: FetchableObject 73 | >: FetchedResultsController, @unchecked Sendable { 74 | private unowned let paginatingDefinition: PaginatingFetchDefinition 75 | 76 | public init( 77 | definition: PaginatingFetchDefinition, 78 | sortDescriptors: [NSSortDescriptor] = [], 79 | sectionNameKeyPath: SectionNameKeyPath? = nil, 80 | debounceInsertsAndReloads: Bool = true 81 | ) { 82 | paginatingDefinition = definition 83 | 84 | super.init( 85 | definition: definition, 86 | sortDescriptors: sortDescriptors, 87 | sectionNameKeyPath: sectionNameKeyPath, 88 | debounceInsertsAndReloads: debounceInsertsAndReloads 89 | ) 90 | } 91 | 92 | @MainActor 93 | public func performPagination(completion: @escaping @MainActor (_ hasPageResults: Bool) -> Void = { _ in }) { 94 | performPagination( 95 | with: paginatingDefinition.paginationRequest, 96 | willDebounceInsertsAndReloads: debounceInsertsAndReloads, 97 | completion: completion 98 | ) 99 | } 100 | 101 | @MainActor 102 | public func performPagination() async -> Bool { 103 | await withCheckedContinuation { continuation in 104 | performPagination { hasPageResults in 105 | continuation.resume(returning: hasPageResults) 106 | } 107 | } 108 | } 109 | } 110 | 111 | public class PausablePaginatingFetchedResultsController< 112 | FetchedObject: FetchableObject 113 | >: PausableFetchedResultsController { 114 | private unowned let paginatingDefinition: PaginatingFetchDefinition 115 | 116 | public init( 117 | definition: PaginatingFetchDefinition, 118 | sortDescriptors: [NSSortDescriptor] = [], 119 | sectionNameKeyPath: SectionNameKeyPath? = nil, 120 | debounceInsertsAndReloads: Bool = true 121 | ) { 122 | paginatingDefinition = definition 123 | 124 | super.init( 125 | definition: definition, 126 | sortDescriptors: sortDescriptors, 127 | sectionNameKeyPath: sectionNameKeyPath, 128 | debounceInsertsAndReloads: debounceInsertsAndReloads 129 | ) 130 | } 131 | 132 | @MainActor 133 | public func performPagination() { 134 | performPagination( 135 | with: paginatingDefinition.paginationRequest, 136 | willDebounceInsertsAndReloads: debounceInsertsAndReloads, 137 | completion: { _ in } 138 | ) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Example/iOS-Example/Model.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 7/2/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FetchRequests 12 | 13 | @dynamicMemberLookup 14 | final class Model: NSObject, @unchecked Sendable { 15 | typealias ID = String 16 | 17 | struct RawData: Codable, Identifiable, Equatable { 18 | let id: ID 19 | let createdAt: Date 20 | var updatedAt: Date 21 | } 22 | 23 | @ObservableValue 24 | var data: RawData { 25 | willSet { 26 | integrate(data: newValue) 27 | } 28 | } 29 | 30 | @objc 31 | dynamic private(set) var updatedAt: Date = .distantPast 32 | 33 | @objc 34 | dynamic private(set) var isDeleted: Bool = false 35 | 36 | @objc 37 | dynamic var observingUpdates: Bool = false { 38 | didSet { 39 | guard observingUpdates != oldValue else { 40 | return 41 | } 42 | 43 | if observingUpdates { 44 | startObservingEvents() 45 | } else { 46 | stopObservingEvents() 47 | } 48 | } 49 | } 50 | 51 | subscript(dynamicMember keyPath: KeyPath) -> Property { 52 | data[keyPath: keyPath] 53 | } 54 | 55 | override init() { 56 | self.data = RawData( 57 | id: UUID().uuidString, 58 | createdAt: Date(), 59 | updatedAt: Date() 60 | ) 61 | super.init() 62 | integrate(data: data) 63 | } 64 | 65 | required init(data: RawData) { 66 | self.data = data 67 | super.init() 68 | integrate(data: data) 69 | } 70 | 71 | // MARK: - NSObject Overrides 72 | 73 | override func isEqual(_ object: Any?) -> Bool { 74 | guard let other = object as? Model else { 75 | return false 76 | } 77 | 78 | return id == other.id 79 | } 80 | 81 | override var hash: Int { 82 | var hasher = Hasher() 83 | hasher.combine(id) 84 | 85 | return hasher.finalize() 86 | } 87 | } 88 | 89 | // MARK: - Identifiable 90 | 91 | extension Model: Identifiable { 92 | var id: ID { 93 | data.id 94 | } 95 | } 96 | 97 | // MARK: - Persistence Operations 98 | 99 | extension Model { 100 | func save() throws { 101 | try Self.save(self) 102 | } 103 | 104 | func delete() throws { 105 | try Self.delete(self) 106 | } 107 | } 108 | 109 | // MARK: - FetchableObjectProtocol 110 | 111 | extension Model: FetchableObjectProtocol { 112 | func observeDataChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { 113 | _data.observeChanges { change in 114 | handler() 115 | } 116 | } 117 | 118 | func observeIsDeletedChanges(_ handler: @escaping @MainActor () -> Void) -> InvalidatableToken { 119 | self.observe(\.isDeleted, options: [.old, .new]) { object, change in 120 | MainActor.assumeIsolated { 121 | guard let old = change.oldValue, let new = change.newValue, old != new else { 122 | return 123 | } 124 | handler() 125 | } 126 | } 127 | } 128 | 129 | static func entityID(from data: RawData) -> Model.ID? { 130 | data.id 131 | } 132 | 133 | func listenForUpdates() { 134 | observingUpdates = true 135 | } 136 | } 137 | 138 | // MARK: - Private Helpers 139 | 140 | private extension Model { 141 | func integrate(data: RawData) { 142 | // We only need to set KVO-able properties since we're using @dynamicMemberLookup 143 | updatedAt = data.updatedAt 144 | } 145 | 146 | func stopObservingEvents() { 147 | NotificationCenter.default.removeObserver( 148 | self, 149 | name: self.rawObjectEventUpdated(), 150 | object: nil 151 | ) 152 | } 153 | 154 | func startObservingEvents() { 155 | NotificationCenter.default.addObserver( 156 | self, 157 | selector: #selector(dataUpdateNotification), 158 | name: self.rawObjectEventUpdated(), 159 | object: nil 160 | ) 161 | } 162 | 163 | @objc 164 | func dataUpdateNotification(notification: Notification) { 165 | guard let model = notification.object as? Model, self == model else { 166 | fatalError("Bad notification with object \(String(describing: notification.object))") 167 | } 168 | 169 | guard model.updatedAt > self.updatedAt else { 170 | return 171 | } 172 | 173 | guard notification.userInfo != nil else { 174 | processDelete(of: model) 175 | return 176 | } 177 | 178 | guard let data = notification.userInfo?["data"] as? RawData else { 179 | let info = notification.userInfo ?? [:] 180 | fatalError("Bad notification with userInfo \(info)") 181 | } 182 | processUpdate(of: model, with: data) 183 | } 184 | 185 | func processUpdate(of model: Model, with data: RawData) { 186 | assert(Thread.isMainThread, "\(#function) must be called on the main thread") 187 | 188 | self.data = data 189 | if isDeleted { 190 | isDeleted = false 191 | } 192 | } 193 | 194 | func processDelete(of model: Model) { 195 | assert(Thread.isMainThread, "\(#function) must be called on the main thread") 196 | 197 | isDeleted = true 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /FetchRequests/Sources/SwiftUI/FetchableRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchableRequest.swift 3 | // 4 | // Created by Adam Lickel on 6/10/21. 5 | // 6 | 7 | import Foundation 8 | import Combine 9 | import SwiftUI 10 | 11 | #if compiler(>=6) 12 | extension SectionedFetchableRequest: @preconcurrency DynamicProperty {} 13 | #else 14 | extension SectionedFetchableRequest: DynamicProperty {} 15 | #endif 16 | 17 | @propertyWrapper 18 | public struct SectionedFetchableRequest { 19 | @FetchableRequest 20 | private var base: FetchableResults 21 | 22 | public var wrappedValue: SectionedFetchableResults { 23 | SectionedFetchableResults(contents: _base.fetchController.sections) 24 | } 25 | 26 | /// Has performFetch() completed? 27 | public var hasFetchedObjects: Bool { 28 | _base.hasFetchedObjects 29 | } 30 | 31 | public init( 32 | definition: FetchDefinition, 33 | sectionNameKeyPath: FetchedResultsController.SectionNameKeyPath, 34 | sortDescriptors: [NSSortDescriptor] = [], 35 | debounceInsertsAndReloads: Bool = true, 36 | animation: Animation? = nil 37 | ) { 38 | let controller = FetchedResultsController( 39 | definition: definition, 40 | sortDescriptors: sortDescriptors, 41 | sectionNameKeyPath: sectionNameKeyPath, 42 | debounceInsertsAndReloads: debounceInsertsAndReloads 43 | ) 44 | 45 | _base = FetchableRequest(controller: controller, animation: animation) 46 | } 47 | 48 | @MainActor 49 | public mutating func update() { 50 | _base.update() 51 | } 52 | } 53 | 54 | private class Opaque { 55 | var value: T? 56 | } 57 | 58 | #if compiler(>=6) 59 | extension FetchableRequest: @preconcurrency DynamicProperty {} 60 | #else 61 | extension FetchableRequest: DynamicProperty {} 62 | #endif 63 | 64 | @propertyWrapper 65 | public struct FetchableRequest { 66 | @State 67 | public private(set) var wrappedValue = FetchableResults() 68 | 69 | @State 70 | fileprivate var fetchController: FetchedResultsController 71 | 72 | @State 73 | private var subscription: Opaque = Opaque() 74 | 75 | private let animation: Animation? 76 | 77 | /// Has performFetch() completed? 78 | public var hasFetchedObjects: Bool { 79 | fetchController.hasFetchedObjects 80 | } 81 | 82 | public init( 83 | definition: FetchDefinition, 84 | sortDescriptors: [NSSortDescriptor] = [], 85 | debounceInsertsAndReloads: Bool = true, 86 | animation: Animation? = nil 87 | ) { 88 | let controller = FetchedResultsController( 89 | definition: definition, 90 | sortDescriptors: sortDescriptors, 91 | debounceInsertsAndReloads: debounceInsertsAndReloads 92 | ) 93 | 94 | self.init(controller: controller, animation: animation) 95 | } 96 | 97 | internal init( 98 | controller: FetchedResultsController, 99 | animation: Animation? = nil 100 | ) { 101 | // With iOS 14 we should use StateObject; however, we may lose animation options 102 | _fetchController = State(initialValue: controller) 103 | self.animation = animation 104 | } 105 | 106 | @MainActor 107 | public mutating func update() { 108 | _wrappedValue.update() 109 | _fetchController.update() 110 | 111 | guard !hasFetchedObjects else { 112 | return 113 | } 114 | 115 | var isSynchronous = true 116 | 117 | defer { 118 | fetchController.performFetch() 119 | isSynchronous = false 120 | } 121 | 122 | let controller = fetchController 123 | let binding = $wrappedValue 124 | let animation = self.animation 125 | 126 | subscription.value = fetchController.objectDidChange.sink { [weak controller] in 127 | guard let controller else { 128 | return 129 | } 130 | let change: () -> Void = { 131 | withAnimation(animation) { 132 | let newVersion = binding.wrappedValue.version + 1 133 | binding.wrappedValue = FetchableResults( 134 | contents: controller.fetchedObjects, 135 | version: newVersion 136 | ) 137 | } 138 | } 139 | 140 | if isSynchronous { 141 | DispatchQueue.main.async { 142 | change() 143 | } 144 | } else { 145 | change() 146 | } 147 | } 148 | } 149 | } 150 | 151 | public struct FetchableResults { 152 | private var contents: [FetchedObject] 153 | fileprivate var version: Int 154 | 155 | fileprivate init( 156 | contents: [FetchedObject] = [], 157 | version: Int = 0 158 | ) { 159 | self.contents = contents 160 | 161 | // Version forces our view to re-render even if the contents haven't changed 162 | // This is necessary because of things like associated values or model updates 163 | self.version = version 164 | } 165 | } 166 | 167 | public struct SectionedFetchableResults { 168 | private let contents: [FetchedResultsSection] 169 | 170 | fileprivate init(contents: [FetchedResultsSection] = []) { 171 | self.contents = contents 172 | } 173 | } 174 | 175 | extension FetchableResults: RandomAccessCollection { 176 | public var startIndex: Int { 177 | contents.startIndex 178 | } 179 | 180 | public var endIndex: Int { 181 | contents.endIndex 182 | } 183 | 184 | public subscript(position: Int) -> FetchedObject { 185 | contents[position] 186 | } 187 | } 188 | 189 | extension SectionedFetchableResults: RandomAccessCollection { 190 | public var startIndex: Int { 191 | contents.startIndex 192 | } 193 | 194 | public var endIndex: Int { 195 | contents.endIndex 196 | } 197 | 198 | public subscript(position: Int) -> FetchedResultsSection { 199 | contents[position] 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Example/iOS-Example/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // iOS Example 4 | // 5 | // Created by Adam Lickel on 7/2/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | import FetchRequests 13 | 14 | class ViewController: UITableViewController { 15 | private(set) lazy var controller: FetchedResultsController = { 16 | let controller: FetchedResultsController = FetchedResultsController( 17 | definition: Model.fetchDefinition(), 18 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.updatedAt, ascending: false)] 19 | ) 20 | controller.setDelegate(self) 21 | return controller 22 | }() 23 | 24 | private class Cell: UITableViewCell { 25 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 26 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | super.init(coder: coder) 31 | } 32 | 33 | class var reuseIdentifier: String { 34 | NSStringFromClass(self) 35 | } 36 | } 37 | } 38 | 39 | // MARK: - View Lifecycle 40 | 41 | extension ViewController { 42 | override func viewDidLoad() { 43 | super.viewDidLoad() 44 | 45 | title = NSLocalizedString("iOS Example", comment: "iOS Example") 46 | 47 | navigationItem.leftBarButtonItem = UIBarButtonItem( 48 | barButtonSystemItem: .trash, 49 | target: self, 50 | action: #selector(clearContents) 51 | ) 52 | 53 | navigationItem.rightBarButtonItem = UIBarButtonItem( 54 | barButtonSystemItem: .add, 55 | target: self, 56 | action: #selector(addItem) 57 | ) 58 | toolbarItems = [UIBarButtonItem(title: "Swift UI", style: .plain, target: self, action: #selector(showSwiftUI))] 59 | navigationController?.setToolbarHidden(false, animated: false) 60 | 61 | tableView.register(Cell.self, forCellReuseIdentifier: Cell.reuseIdentifier) 62 | } 63 | 64 | override func viewDidAppear(_ animated: Bool) { 65 | super.viewDidAppear(animated) 66 | 67 | controller.performFetch() 68 | } 69 | } 70 | 71 | // MARK: - UITableViewDataSource 72 | 73 | extension ViewController { 74 | override func numberOfSections(in tableView: UITableView) -> Int { 75 | controller.sections.count 76 | } 77 | 78 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 79 | controller.sections[section].numberOfObjects 80 | } 81 | 82 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 83 | guard let cell = tableView.dequeueReusableCell(withIdentifier: Cell.reuseIdentifier) else { 84 | fatalError("Cell reuse failed") 85 | } 86 | let model = controller.object(at: indexPath) 87 | cell.textLabel?.text = model.id 88 | cell.detailTextLabel?.text = model.createdAt.description 89 | return cell 90 | } 91 | } 92 | 93 | // MARK: - UITableViewDelegate 94 | 95 | extension ViewController { 96 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 97 | tableView.deselectRow(at: indexPath, animated: true) 98 | 99 | let model = controller.object(at: indexPath) 100 | try? model.save() 101 | } 102 | 103 | override func tableView( 104 | _ tableView: UITableView, 105 | trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath 106 | ) -> UISwipeActionsConfiguration? { 107 | let delete = UIContextualAction( 108 | style: .destructive, 109 | title: NSLocalizedString("Delete", comment: "Delete") 110 | ) { [weak self] action, view, completion in 111 | guard let self else { 112 | return 113 | } 114 | let model = self.controller.object(at: indexPath) 115 | try? model.delete() 116 | } 117 | 118 | return UISwipeActionsConfiguration(actions: [delete]) 119 | } 120 | } 121 | 122 | // MARK: - Events 123 | 124 | private extension ViewController { 125 | @objc 126 | func clearContents(_ sender: Any) { 127 | Model.reset() 128 | } 129 | 130 | @objc 131 | func addItem(_ sender: Any) { 132 | try? Model().save() 133 | } 134 | 135 | @objc 136 | func showSwiftUI(_ sender: Any) { 137 | present(UIHostingController(rootView: SwiftUIView()), animated: true) 138 | } 139 | } 140 | 141 | // MARK: - FetchedResultsControllerDelegate 142 | 143 | extension ViewController: FetchedResultsControllerDelegate { 144 | func controllerWillChangeContent(_ controller: FetchedResultsController) { 145 | tableView.beginUpdates() 146 | } 147 | 148 | func controllerDidChangeContent(_ controller: FetchedResultsController) { 149 | tableView.endUpdates() 150 | } 151 | 152 | func controller( 153 | _ controller: FetchedResultsController, 154 | didChange object: Model, 155 | for change: FetchedResultsChange 156 | ) { 157 | switch change { 158 | case let .insert(newIndexPath): 159 | tableView.insertRows(at: [newIndexPath], with: .automatic) 160 | 161 | case let .update(indexPath): 162 | tableView.reloadRows(at: [indexPath], with: .none) 163 | 164 | case let .delete(indexPath): 165 | tableView.deleteRows(at: [indexPath], with: .automatic) 166 | 167 | case let .move(indexPath, newIndexPath): 168 | tableView.moveRow(at: indexPath, to: newIndexPath) 169 | } 170 | } 171 | 172 | func controller( 173 | _ controller: FetchedResultsController, 174 | didChange section: FetchedResultsSection, 175 | for change: FetchedResultsChange 176 | ) { 177 | switch change { 178 | case let .insert(index): 179 | tableView.insertSections([index], with: .automatic) 180 | 181 | case let .update(index): 182 | tableView.reloadSections([index], with: .automatic) 183 | 184 | case let .delete(index): 185 | tableView.deleteSections([index], with: .automatic) 186 | 187 | case let .move(index, newIndex): 188 | tableView.moveSection(index, toSection: newIndex) 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Associations/ObservableToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableToken.swift 3 | // FetchRequests-iOS 4 | // 5 | // Created by Adam Lickel on 2/22/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func synchronized(_ lockObject: AnyObject, block: () -> T) -> T { 12 | objc_sync_enter(lockObject) 13 | defer { 14 | objc_sync_exit(lockObject) 15 | } 16 | 17 | return block() 18 | } 19 | 20 | public protocol InvalidatableToken: AnyObject { 21 | func invalidate() 22 | } 23 | 24 | public protocol ObservableToken: InvalidatableToken { 25 | associatedtype Parameter 26 | 27 | func observe(handler: @escaping @Sendable @MainActor (Parameter) -> Void) 28 | } 29 | 30 | /// This is a hack and should not be necessary 31 | struct UnsafeSendableWrapper: @unchecked Sendable { 32 | let value: Value 33 | } 34 | 35 | public class ObservableNotificationCenterToken: ObservableToken { 36 | private let name: Notification.Name 37 | private unowned let notificationCenter: NotificationCenter 38 | private var centerToken: NSObjectProtocol? 39 | 40 | public init( 41 | name: Notification.Name, 42 | notificationCenter: NotificationCenter = .default 43 | ) { 44 | self.name = name 45 | self.notificationCenter = notificationCenter 46 | } 47 | 48 | public func observe(handler: @escaping @Sendable @MainActor (Notification) -> Void) { 49 | centerToken = notificationCenter.addObserver( 50 | forName: name, 51 | object: nil, 52 | queue: .main 53 | ) { notification in 54 | let wrapper = UnsafeSendableWrapper(value: notification) 55 | MainActor.assumeIsolated { 56 | handler(wrapper.value) 57 | } 58 | } 59 | } 60 | 61 | public func invalidate() { 62 | defer { 63 | centerToken = nil 64 | } 65 | guard let existing = centerToken else { 66 | return 67 | } 68 | notificationCenter.removeObserver(existing) 69 | } 70 | 71 | deinit { 72 | self.invalidate() 73 | } 74 | } 75 | 76 | extension NSKeyValueObservation: InvalidatableToken {} 77 | 78 | internal class LegacyKeyValueObserving: NSObject, InvalidatableToken { 79 | typealias Handler = (_ object: Object, _ oldValue: Value?, _ newValue: Value?) -> Void 80 | 81 | private weak var object: Object? 82 | private let keyPath: String 83 | private let handler: Handler 84 | 85 | private var unsafeIsObserving = true 86 | 87 | convenience init( 88 | object: Object, 89 | keyPath: AnyKeyPath, 90 | type: Value.Type, 91 | handler: @escaping Handler 92 | ) { 93 | self.init( 94 | object: object, 95 | keyPath: keyPath._kvcKeyPathString!, 96 | type: type, 97 | handler: handler 98 | ) 99 | } 100 | 101 | init( 102 | object: Object, 103 | keyPath: String, 104 | type: Value.Type, 105 | handler: @escaping Handler 106 | ) { 107 | self.object = object 108 | self.keyPath = keyPath 109 | self.handler = handler 110 | 111 | super.init() 112 | 113 | object.addObserver(self, forKeyPath: keyPath, options: [.old, .new], context: nil) 114 | } 115 | 116 | func invalidate() { 117 | synchronized(self) { 118 | guard unsafeIsObserving else { 119 | return 120 | } 121 | defer { 122 | unsafeIsObserving = false 123 | } 124 | 125 | object?.removeObserver(self, forKeyPath: keyPath) 126 | } 127 | } 128 | 129 | deinit { 130 | invalidate() 131 | } 132 | 133 | // swiftlint:disable:next block_based_kvo 134 | override func observeValue( 135 | forKeyPath keyPath: String?, 136 | of object: Any?, 137 | change: [NSKeyValueChangeKey: Any]?, 138 | context: UnsafeMutableRawPointer? 139 | ) { 140 | guard let typedObject = object as? Object, 141 | typedObject == self.object, 142 | keyPath == self.keyPath 143 | else { 144 | return super.observeValue( 145 | forKeyPath: keyPath, 146 | of: object, 147 | change: change, 148 | context: context 149 | ) 150 | } 151 | 152 | let oldValue = change?[.oldKey] as? Value 153 | let newValue = change?[.newKey] as? Value 154 | 155 | handler(typedObject, oldValue, newValue) 156 | } 157 | } 158 | 159 | internal class FetchRequestObservableToken: ObservableToken { 160 | typealias Handler = @Sendable @MainActor (Parameter) -> Void 161 | 162 | private let _observe: (_ handler: @escaping Handler) -> Void 163 | private let _invalidate: () -> Void 164 | 165 | var isObserving: Bool { 166 | synchronized(self) { 167 | unsafeIsObserving 168 | } 169 | } 170 | 171 | private var unsafeIsObserving = false 172 | 173 | private init( 174 | observe: @escaping (_ handler: @escaping Handler) -> Void, 175 | invalidate: @escaping () -> Void 176 | ) { 177 | _observe = observe 178 | _invalidate = invalidate 179 | } 180 | 181 | init(token: some ObservableToken) { 182 | _observe = { token.observe(handler: $0) } 183 | _invalidate = { token.invalidate() } 184 | } 185 | 186 | func observeIfNeeded(handler: @escaping Handler) { 187 | synchronized(self) { 188 | guard !unsafeIsObserving else { 189 | return 190 | } 191 | defer { 192 | unsafeIsObserving = true 193 | } 194 | 195 | observe(handler: handler) 196 | } 197 | } 198 | 199 | func observe(handler: @escaping Handler) { 200 | synchronized(self) { 201 | _observe(handler) 202 | } 203 | } 204 | 205 | func invalidateIfNeeded() { 206 | synchronized(self) { 207 | guard unsafeIsObserving else { 208 | return 209 | } 210 | defer { 211 | unsafeIsObserving = false 212 | } 213 | 214 | invalidate() 215 | } 216 | } 217 | 218 | func invalidate() { 219 | synchronized(self) { 220 | _invalidate() 221 | } 222 | } 223 | 224 | deinit { 225 | guard isObserving else { 226 | return 227 | } 228 | assert(false) 229 | invalidate() 230 | } 231 | } 232 | 233 | extension FetchRequestObservableToken where Parameter == Any { 234 | convenience init(typeErasedToken: Token) { 235 | self.init( 236 | observe: { typeErasedToken.observe(handler: $0) }, 237 | invalidate: { typeErasedToken.invalidate() } 238 | ) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FetchRequests 2 | 3 | FetchRequests is an eventing library inspired by NSFetchedResultsController and written in Swift. 4 | 5 | [![Swift 6](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsquare%2FFetchRequests%2Fbadge%3Ftype%3Dswift-versions&logo=swift&logoColor=white&label=swift)](https://swiftpackageindex.com/square/FetchRequests) 6 | [![Release](https://img.shields.io/github/v/release/square/FetchRequests)](https://github.com/square/FetchRequests/releases) 7 | [![Build Status](https://img.shields.io/github/actions/workflow/status/square/FetchRequests/build.yml)](https://github.com/square/FetchRequests/actions/workflows/build.yml) 8 | [![codecov](https://img.shields.io/codecov/c/github/square/FetchRequests/main)](https://codecov.io/gh/square/FetchRequests) 9 | [![Platform](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsquare%2FFetchRequests%2Fbadge%3Ftype%3Dplatforms&logo=none&label=platforms)](https://swiftpackageindex.com/square/FetchRequests) 10 | [![License](https://img.shields.io/github/license/square/FetchRequests)](https://opensource.org/licenses/MIT) 11 | 12 | - [Features](#features) 13 | - [Usage](#usage) 14 | - [Requirements](#requirements) 15 | - [Communication](#communication) 16 | - [Installation](#installation) 17 | - [License](#license) 18 | 19 | ## Features 20 | 21 | - [x] Sort and section a list of items 22 | - [x] Listen for live updates 23 | - [x] Animate underlying data changes 24 | - [x] Fetch associated values in batch 25 | - [x] Support paginated requests 26 | - [x] SwiftUI Integration 27 | - [x] Comprehensive Unit Test Coverage 28 | 29 | ## Usage 30 | 31 | FetchRequests can be used for any combination of networking, database, and file queries. 32 | It is best when backed by something like a [WebSocket](https://en.wikipedia.org/wiki/WebSocket) where you're expecting your data to live update. 33 | 34 | To get started, you create a `FetchRequest` which explains your data access patterns. 35 | The `FetchedResultsController` is the interface to access the your data. 36 | Objects are sorted with the following heuristic: 37 | * Section Name ascending (if present) 38 | * Passed in sort descriptors 39 | * Insertion order of the entity ascending 40 | 41 | You can associate related values to your fetched entities. 42 | It will automatically cache these associated values for the lifetime of that controller. 43 | If a memory pressure event occurs, it will release its hold on those objects, allowing them to be de-inited. 44 | 45 | The example app has an UserDefaults-backed storage mechanism. 46 | The unit tests have in-memory objects, with NotificationCenter eventing. 47 | 48 | Today, it is heavily dependent on the Obj-C runtime, as well as Key-Value Observation. 49 | It should be possible to further remove those restrictions, and some effort has been made to remove them. 50 | 51 | ### SwiftUI 52 | 53 | There are two SwiftUI Property Wrappers available for use, `FetchableRequest` and `SectionedFetchableRequest`. These are analogous to CoreData's property wrappers. 54 | 55 | The controller will perform a fetch once and only once upon the first view render. After that point, it is dependent upon live update events. 56 | 57 | Examples: 58 | 59 | ```swift 60 | struct AllUsersView: View { 61 | @FetchableRequest( 62 | definition: FetchDefinition(request: User.fetchAll), 63 | sortDescriptors: [ 64 | NSSortDescriptor( 65 | key: #keyPath(User.name), 66 | ascending: true, 67 | selector: #selector(NSString.localizedStandardCompare) 68 | ), 69 | ] 70 | ) 71 | private var members: FetchableResults 72 | 73 | // ... 74 | } 75 | ``` 76 | 77 | For more complicated use cases, you probably will need to write initializers for your view, for example: 78 | 79 | ```swift 80 | struct MembersView: View { 81 | private let fromID: EntityID 82 | 83 | @FetchableRequest 84 | private var members: FetchableResults 85 | 86 | func init(fromID: EntityID) { 87 | self.fromID = fromID 88 | _members = FetchableRequest( 89 | definition: Membership.fetchDefinition(from: fromID, toEntityType: .user) 90 | ) 91 | } 92 | 93 | // ... 94 | } 95 | ``` 96 | 97 | ## Requirements 98 | 99 | - iOS 13+ / macOS 10.15+ / tvOS 13+ / watchOS 6+ / visionOS 1+ 100 | - Xcode 16+ 101 | - Swift 6+ 102 | 103 | When installing via SPM, we will use [Swift Collections](https://github.com/apple/swift-collections). 104 | Otherwise we will use a [less efficient OrderedSet](https://github.com/square/FetchRequests/blob/main/FetchRequests/Sources/OrderedSetCompat.swift). 105 | 106 | ## Communication 107 | 108 | - If you **found a bug**, open an issue. 109 | - If you **have a feature request**, open an issue. 110 | - If you **want to contribute**, submit a pull request. 111 | 112 | ## Installation 113 | 114 | ### Swift Package Manager 115 | 116 | Install with [Swift Package Manager](https://swift.org/package-manager/) by adding it to the `dependencies` value of your `Package.swift`: 117 | 118 | ```swift 119 | dependencies: [ 120 | .package(url: "https://github.com/square/FetchRequests.git", from: "7.0.0") 121 | ] 122 | ``` 123 | 124 | ### CocoaPods 125 | 126 | Install with [CocoaPods](http://cocoapods.org) by specifying the following in your `Podfile`: 127 | 128 | ```ruby 129 | pod 'FetchRequests', '~> 7.0' 130 | ``` 131 | 132 | ### Carthage 133 | 134 | Install with [Carthage](https://github.com/Carthage/Carthage) by specify the following in your `Cartfile`: 135 | 136 | ``` 137 | github "square/FetchRequests" ~> 7.0 138 | ``` 139 | 140 | ## Contributing 141 | 142 | If you would like to contribute code to FetchRequests you can do so through GitHub by forking the repository and sending a pull request. 143 | 144 | When submitting code, please make every effort to follow existing conventions and style in order to keep the code as readable as possible. 145 | 146 | Before your code can be accepted into the project you must also sign the [Individual Contributor License Agreement (CLA)](https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1). 147 | 148 | ### Code of Conduct 149 | 150 | We expect contributors to adhere to the Square Open Source Code of Conduct. A copy of this document is available [here](https://developer.squareup.com/blog/open-source-code-of-conduct). 151 | 152 | ## License 153 | 154 | FetchRequests is released under the MIT license. 155 | 156 | ``` 157 | MIT License 158 | 159 | Copyright (c) 2019 Square Inc. 160 | 161 | Permission is hereby granted, free of charge, to any person obtaining a copy 162 | of this software and associated documentation files (the "Software"), to deal 163 | in the Software without restriction, including without limitation the rights 164 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 165 | copies of the Software, and to permit persons to whom the Software is 166 | furnished to do so, subject to the following conditions: 167 | 168 | The above copyright notice and this permission notice shall be included in all 169 | copies or substantial portions of the Software. 170 | 171 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 172 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 173 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 174 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 175 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 176 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 177 | SOFTWARE. 178 | 179 | ``` 180 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | `FetchRequests` adheres to [Semantic Versioning](https://semver.org/). 4 | 5 | ## [7.0.1](https://github.com/square/FetchRequests/releases/tag/7.0.1) 6 | Release 2024-09-26 7 | 8 | * Restore support for Swift 5 compiler 9 | 10 | ## [7.0](https://github.com/square/FetchRequests/releases/tag/7.0.0) 11 | Release 2024-09-11 12 | 13 | * Compiles cleanly in the Swift 6 language mode 14 | * Requires the Swift 6 compiler 15 | * `JSON` has been removed from the library as it is no longer necessary 16 | 17 | ## [6.1](https://github.com/square/FetchRequests/releases/tag/6.1.0) 18 | Release 2024-04-03 19 | 20 | * Add async overloads for `performFetch() and `resort(using:)` 21 | * Add completion closure to `performPagination()`, with a boolean value indicating if values were returned or not 22 | * Add async overload to `performPagination()` 23 | 24 | ## [6.0.3](https://github.com/square/FetchRequests/releases/tag/6.0.3) 25 | Released on 2024-03-06 26 | 27 | * Cleanup warnings in Xcode 15.3 28 | 29 | ## [6.0.2](https://github.com/square/FetchRequests/releases/tag/6.0.2) 30 | Released on 2023-09-21 31 | 32 | * Cleanup warnings in Xcode 15 33 | 34 | ## [6.0.1](https://github.com/square/FetchRequests/releases/tag/6.0.1) 35 | Released on 2023-06-23 36 | 37 | * The demo now includes a SwiftUI example 38 | * Fix for SwiftUI when a FetchDefinition request is synchronous 39 | 40 | ## [6.0](https://github.com/square/FetchRequests/releases/tag/6.0.0) 41 | Released on 2023-04-05 42 | 43 | * Requires Swift 5.8 44 | * FetchableEntityID's async methods are now marked as @MainActor 45 | * Association Request completion handlers are now marked as @MainActor 46 | * Previously the handler would immediately bounce to the main thread if needed 47 | * Bump deployment targets: 48 | * iOS, tvOS, Catalyst: v14 49 | * watchOS v7 50 | * macOS v11 51 | 52 | ## [5.0](https://github.com/square/FetchRequests/releases/tag/5.0.0) 53 | Released on 2022-10-25 54 | 55 | * Requires Swift 5.7 56 | * Protocols define their primary associated types 57 | * JSON literal arrays and dictionaries now must be strongly typed via the `JSONConvertible` protocol 58 | * Annotate many methods as @MainActor 59 | * All delegate methods 60 | * All code with assert(Thread.isMainThread) 61 | * Faulting an association when you're off the main thread will have different characteristics 62 | * If the association already exists, nothing will change 63 | * If the association does not already exist, it will always return nil and hit the main thread to batch fetch the associations 64 | * More eventing supports occurring off of the main thread 65 | * If needed, it will async bounce to the main thread to actually perform the change 66 | * Newly allowed Events: 67 | * Associated Value creation events 68 | * Entity creation events 69 | * Data reset events 70 | * Note any changes to your model still must occur on the main thread 71 | * data 72 | * isDeleted 73 | * NSSortDescriptor keyPaths 74 | * Association keyPaths 75 | 76 | ## [4.0.4](https://github.com/square/FetchRequests/releases/tag/4.0.4) 77 | Released on 2022-08-30 78 | 79 | * Reduce some cases of no-op eventing 80 | 81 | ## [4.0.3](https://github.com/square/FetchRequests/releases/tag/4.0.3) 82 | Released on 2022-05-09 83 | 84 | * Support array associations by an arbitrary reference instead of just by ID. This is specified via a new referenceAccessor parameter. 85 | * Updated example to use Codable model 86 | * Updated linting 87 | 88 | ## [4.0.2](https://github.com/square/FetchRequests/releases/tag/4.0.2) 89 | Released on 2021-12-14 90 | 91 | * Expose `hasFetchedObjects` on FetchableRequest and SectionedFetchableRequest. It has the same semantics as the property on the Controller. 92 | 93 | ## [4.0.1](https://github.com/square/FetchRequests/releases/tag/4.0.1) 94 | Released on 2021-10-20 95 | 96 | * Respect insertion order for pagination / live updates 97 | 98 | ## [4.0.0](https://github.com/square/FetchRequests/releases/tag/4.0.0) 99 | Released on 2021-09-14 100 | 101 | * Updated minimum SDKs to iOS 13 and related OSes 102 | * Added a Swift 5.5 package definition 103 | * Renamed `FetchRequest` to `FetchDefinition` to avoid SwiftUI naming collisions 104 | * Removed `FRIdentifiable` in deference to `Identifiable` 105 | * Added `Identifiable` conformance to FetchedResultsSection 106 | * Removed simplediff in deference to `BidirectionalCollection.difference(from:)` 107 | * Added `objectWillChange` and `objectDidChange` Publishers to all Controllers 108 | * Removed the Wrapper controller as it is duplicative with `objectDidChange` 109 | * Objects are no longer sorted by `id` 110 | * The `ID` does not need to be comparable 111 | * Stable sorting is maintained by respecting the insertion order of objects 112 | 113 | ## [3.2.0](https://github.com/square/FetchRequests/releases/tag/3.2.0) 114 | Released on 2021-06-21 115 | 116 | * Added FetchableRequest SwiftUI Property Wrapper and friends 117 | 118 | ## [3.1.1](https://github.com/square/FetchRequests/releases/tag/3.1.1) 119 | Released on 2021-05-19 120 | 121 | * Fix warnings related to deprecation of using `class` in protocol definitions 122 | 123 | ## [3.1](https://github.com/square/FetchRequests/releases/tag/3.1) 124 | Released on 2021-01-15 125 | 126 | * Add `func resort(using newSortDescriptors: [NSSortDescriptor])` 127 | 128 | ## [3.0.2](https://github.com/square/FetchRequests/releases/tag/3.0.2) 129 | Released on 2020-11-19 130 | 131 | * Expose test target in Swift Package Manager 132 | * Fix thread safety bug with insertion 133 | 134 | ## [3.0.1](https://github.com/square/FetchRequests/releases/tag/3.0.1) 135 | Released on 2020-10-26 136 | 137 | * Tweaked logging format 138 | 139 | ## [3.0](https://github.com/square/FetchRequests/releases/tag/3.0) 140 | Released on 2020-09-15 141 | 142 | * Renamed Identifiable to FRIdentifiable to avoid naming collisions with Swift.Identifiable 143 | * Changed to require Xcode 12, and increased the minimum OS by 2, so iOS 14, tvOS 14, macOS 10.14, & watchOS 5 144 | * RawDataRepresentable.RawData is now an associatedtype as the tests execute cleanly in Xcode 12 145 | * Note: The JSON type is still being vended by this framework 146 | 147 | ## [2.2.1](https://github.com/square/FetchRequests/releases/tag/2.2.1) 148 | Released on 2020-09-02 149 | 150 | * Made Sequence.sorted(by comparator: Comparator) private 151 | 152 | ## [2.2](https://github.com/square/FetchRequests/releases/tag/2.2) 153 | Released on 2019-12-05 154 | 155 | * BoxedJSON.init(__object: NSObject?) for Obj-C uses 156 | 157 | ## [2.1](https://github.com/square/FetchRequests/releases/tag/2.1) 158 | Released on 2019-12-02 159 | 160 | * Support NS(Secure)Coding in BoxedJSON 161 | 162 | ## [2.0](https://github.com/square/FetchRequests/releases/tag/2.0) 163 | Released on 2019-11-15 164 | 165 | * Change `objectID` to `id` 166 | * Remove a bunch of KVO requirements and weird conformance rules 167 | * Remove the CW prefix from everything 168 | * Change the `RawData` type from `[String: Any]` to `JSON` 169 | * `JSON` is an equatable struct 170 | * It supports dynamic member lookup 171 | * It does lazy initialization and lookup, so it's very cheap 172 | * It is bridgeable to Obj-C, so you can still use KVO with it 173 | 174 | Sadly, we still cannot make `data` an `associatedtype`. Something about it breaks the runtime. 175 | 176 | ## [1.0.2](https://github.com/square/FetchRequests/releases/tag/1.0.2) 177 | Released on 2019-08-01. 178 | 179 | * CWObservableNotificationCenterToken will automatically invalidate itself on deinit 180 | * Added SwiftLint validation 181 | 182 | ## [1.0.1](https://github.com/square/FetchRequests/releases/tag/1.0.1) 183 | Released on 2019-07-02. 184 | 185 | * Adds an example app 186 | 187 | ## [1.0.0](https://github.com/square/FetchRequests/releases/tag/1.0.0) 188 | Released on 2019-07-01. 189 | 190 | #### Added 191 | - Initial release of FetchRequests 192 | -------------------------------------------------------------------------------- /FetchRequests/Sources/OrderedSetCompat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrderedSetCompat.swift 3 | // OrderedSetCompat 4 | // 5 | // Created by Adam Lickel on 7/20/21. 6 | // Copyright © 2021 Speramus Inc. All rights reserved. 7 | // 8 | 9 | #if canImport(Collections) 10 | 11 | import Collections 12 | 13 | typealias OrderedSet = Collections.OrderedSet 14 | 15 | #else 16 | 17 | import Foundation 18 | 19 | struct OrderedSet { 20 | private(set) var elements: [Element] { 21 | didSet { 22 | reindex() 23 | } 24 | } 25 | 26 | private(set) var unordered: Set 27 | private var indexed: [Element: Int] 28 | 29 | init() { 30 | elements = [] 31 | unordered = [] 32 | indexed = [:] 33 | } 34 | 35 | init(_ sequence: S) where S.Element == Element { 36 | self.init() 37 | sequence.forEach { insert($0) } 38 | } 39 | } 40 | 41 | // MARK: - Sendable 42 | 43 | extension OrderedSet: Sendable where Element: Sendable {} 44 | 45 | // MARK: - Capacity 46 | 47 | extension OrderedSet { 48 | mutating func removeAll(keepingCapacity keepCapacity: Bool = false) { 49 | elements.removeAll(keepingCapacity: keepCapacity) 50 | unordered.removeAll(keepingCapacity: keepCapacity) 51 | } 52 | 53 | mutating func reserveCapacity(_ minimumCapacity: Int) { 54 | elements.reserveCapacity(minimumCapacity) 55 | unordered.reserveCapacity(minimumCapacity) 56 | } 57 | 58 | var capacity: Int { 59 | elements.capacity 60 | } 61 | } 62 | 63 | // MARK: - Equatable 64 | 65 | extension OrderedSet: Equatable { 66 | static func == (lhs: Self, rhs: Self) -> Bool { 67 | lhs.elements == rhs.elements 68 | } 69 | } 70 | 71 | // MARK: - Hashable 72 | 73 | extension OrderedSet: Hashable { 74 | func hash(into hasher: inout Hasher) { 75 | elements.hash(into: &hasher) 76 | } 77 | } 78 | 79 | // MARK: - CustomStringConvertible 80 | 81 | extension OrderedSet: CustomStringConvertible { 82 | var description: String { 83 | elements.description 84 | } 85 | } 86 | 87 | // MARK: - ExpressibleByArrayLiteral 88 | 89 | extension OrderedSet: ExpressibleByArrayLiteral { 90 | init(arrayLiteral elements: Element...) { 91 | self.init(elements) 92 | } 93 | } 94 | 95 | // MARK: - SetAlgebra 96 | 97 | extension OrderedSet: SetAlgebra { 98 | func contains(_ member: Element) -> Bool { 99 | unordered.contains(member) 100 | } 101 | 102 | @discardableResult 103 | mutating func remove(_ member: Element) -> Element? { 104 | guard contains(member), let index = index(of: member) else { 105 | return nil 106 | } 107 | elements.remove(at: index) 108 | return unordered.remove(member) 109 | } 110 | 111 | @discardableResult 112 | mutating func update(with newMember: Element) -> Element? { 113 | // Unconditionally update our set, even if our set contains it 114 | 115 | if contains(newMember), let index = index(of: newMember) { 116 | elements[index] = newMember 117 | return unordered.update(with: newMember) 118 | } else { 119 | insert(newMember) 120 | return nil 121 | } 122 | } 123 | 124 | @discardableResult 125 | mutating func insert( 126 | _ newMember: Element 127 | ) -> (inserted: Bool, memberAfterInsert: Element) { 128 | // Conditionally update our set, iff our set does not contain it 129 | 130 | guard contains(newMember) else { 131 | elements.append(newMember) 132 | unordered.insert(newMember) 133 | return (true, newMember) 134 | } 135 | 136 | // This should return (false, oldMember) 137 | return unordered.insert(newMember) 138 | } 139 | 140 | // Copies 141 | 142 | func union( 143 | _ other: S 144 | ) -> OrderedSet where S.Element == Element { 145 | var copy = self 146 | copy.formUnion(other) 147 | return copy 148 | } 149 | 150 | func intersection( 151 | _ other: S 152 | ) -> OrderedSet where S.Element == Element { 153 | var copy = self 154 | copy.formIntersection(other) 155 | return copy 156 | } 157 | 158 | func symmetricDifference( 159 | _ other: S 160 | ) -> OrderedSet where S.Element == Element { 161 | var copy = self 162 | copy.formSymmetricDifference(other) 163 | return copy 164 | } 165 | 166 | func subtracting( 167 | _ other: S 168 | ) -> OrderedSet where S.Element == Element { 169 | var copy = self 170 | copy.subtract(other) 171 | return copy 172 | } 173 | 174 | // Mutating 175 | 176 | mutating func formUnion( 177 | _ other: S 178 | ) where S.Element == Element { 179 | let maxCapacity = elements.count + other.underestimatedCount 180 | if capacity < maxCapacity { 181 | reserveCapacity(maxCapacity) 182 | } 183 | 184 | other.forEach { insert($0) } 185 | } 186 | 187 | mutating func formIntersection( 188 | _ other: S 189 | ) where S.Element == Element { 190 | unordered.formIntersection(other) 191 | let newElements = elements.filter { !unordered.contains($0) } 192 | elements = newElements 193 | } 194 | 195 | mutating func formSymmetricDifference( 196 | _ other: S 197 | ) where S.Element == Element { 198 | let maxCapacity = elements.count + other.underestimatedCount 199 | if capacity < maxCapacity { 200 | reserveCapacity(maxCapacity) 201 | } 202 | 203 | for member in other { 204 | if contains(member) { 205 | remove(member) 206 | } else { 207 | insert(member) 208 | } 209 | } 210 | } 211 | 212 | mutating func subtract( 213 | _ other: S 214 | ) where S.Element == Element { 215 | unordered.subtract(other) 216 | let newElements = elements.filter { !unordered.contains($0) } 217 | elements = newElements 218 | } 219 | } 220 | 221 | // MARK: - Collection 222 | 223 | extension OrderedSet: Collection { 224 | var count: Int { 225 | elements.count 226 | } 227 | 228 | var isEmpty: Bool { 229 | elements.isEmpty 230 | } 231 | 232 | mutating func removeFirst() -> Element { 233 | let firstElement = elements.removeFirst() 234 | unordered.remove(firstElement) 235 | return firstElement 236 | } 237 | } 238 | 239 | // MARK: - BidirectionalCollection 240 | 241 | extension OrderedSet: BidirectionalCollection { 242 | mutating func removeLast() -> Element { 243 | let lastElement = elements.removeLast() 244 | unordered.remove(lastElement) 245 | return lastElement 246 | } 247 | } 248 | 249 | // MARK: - RandomAccessCollection 250 | 251 | extension OrderedSet: RandomAccessCollection { 252 | var startIndex: Int { 253 | elements.startIndex 254 | } 255 | 256 | var endIndex: Int { 257 | elements.endIndex 258 | } 259 | 260 | subscript(position: Int) -> Element { 261 | elements[position] 262 | } 263 | } 264 | 265 | // MARK: - O(1) Index access 266 | 267 | extension OrderedSet { 268 | private mutating func reindex() { 269 | indexed = elements.enumerated().reduce(into: [:]) { memo, entry in 270 | memo[entry.element] = entry.offset 271 | } 272 | } 273 | 274 | private func index(of element: Element) -> Int? { 275 | indexed[element] 276 | } 277 | 278 | public func firstIndex(of element: Element) -> Int? { 279 | index(of: element) 280 | } 281 | 282 | public func lastIndex(of element: Element) -> Int? { 283 | index(of: element) 284 | } 285 | } 286 | 287 | #endif 288 | -------------------------------------------------------------------------------- /FetchRequests/Tests/TestObject+Associations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestObject+Associations.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 3/29/19. 6 | // Copyright © 2019 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import FetchRequests 11 | 12 | extension TestObject { 13 | func tagString() -> String? { 14 | performFault(on: \.tag) { tag in 15 | fatalError("Cannot perform fallback fault") 16 | } 17 | } 18 | 19 | func tagObject() -> TestObject? { 20 | performFault(on: \.tagID) { (tagID: String) -> TestObject? in 21 | fatalError("Cannot perform fallback fault") 22 | } 23 | } 24 | 25 | func tagObjectArray() -> [TestObject]? { 26 | performFault(on: \.tagIDs) { (tagIDs: [String]) -> [TestObject]? in 27 | fatalError("Cannot perform fallback fault") 28 | } 29 | } 30 | } 31 | 32 | // MARK: - Association Requests 33 | 34 | extension TestObject { 35 | enum AssociationRequest { 36 | case parents([TestObject], completion: @MainActor ([String: String]) -> Void) 37 | case tagIDs([String], completion: @MainActor ([TestObject]) -> Void) 38 | 39 | // swiftlint:disable implicitly_unwrapped_optional 40 | 41 | var parentIDs: [String]! { 42 | guard case let .parents(objects, _) = self else { 43 | return nil 44 | } 45 | return objects.map(\.id) 46 | } 47 | 48 | var tagIDs: [String]! { 49 | guard case let .tagIDs(objects, _) = self else { 50 | return nil 51 | } 52 | return objects 53 | } 54 | 55 | var parentsCompletion: (@MainActor ([String: String]) -> Void)! { 56 | guard case let .parents(_, completion) = self else { 57 | return nil 58 | } 59 | return completion 60 | } 61 | 62 | var tagIDsCompletion: (@MainActor ([TestObject]) -> Void)! { 63 | guard case let .tagIDs(_, completion) = self else { 64 | return nil 65 | } 66 | return completion 67 | } 68 | 69 | // swiftlint:enable implicitly_unwrapped_optional 70 | } 71 | 72 | static func fetchRequestAssociations( 73 | matching: [PartialKeyPath], 74 | request: @escaping @Sendable @MainActor (AssociationRequest) -> Void 75 | ) -> [FetchRequestAssociation] { 76 | let tagString = FetchRequestAssociation( 77 | keyPath: \.tag, 78 | request: { objects, completion in 79 | request(.parents(objects, completion: completion)) 80 | } 81 | ) 82 | 83 | let tagObject = FetchRequestAssociation( 84 | for: TestObject.self, 85 | keyPath: \.tagID, 86 | request: { objectIDs, completion in 87 | request(.tagIDs(objectIDs, completion: completion)) 88 | } 89 | ) 90 | 91 | let tagObjects = FetchRequestAssociation( 92 | for: [TestObject].self, 93 | keyPath: \.tagIDs, 94 | request: { objectIDs, completion in 95 | request(.tagIDs(objectIDs, completion: completion)) 96 | } 97 | ) 98 | 99 | let allAssociations = [tagString, tagObject, tagObjects] 100 | 101 | return allAssociations.filter { 102 | matching.contains($0.keyPath) 103 | } 104 | } 105 | } 106 | 107 | // MARK: - Tokens 108 | 109 | class WrappedObservableToken: ObservableToken { 110 | private let notificationToken: ObservableNotificationCenterToken 111 | private let transform: @Sendable (Notification) -> T? 112 | 113 | init( 114 | name: Notification.Name, 115 | transform: @escaping @Sendable (Notification) -> T? 116 | ) { 117 | notificationToken = ObservableNotificationCenterToken(name: name) 118 | self.transform = transform 119 | } 120 | 121 | func invalidate() { 122 | notificationToken.invalidate() 123 | } 124 | 125 | func observe(handler: @escaping @Sendable @MainActor (T) -> Void) { 126 | let transform = self.transform 127 | notificationToken.observe { notification in 128 | guard let value = transform(notification) else { 129 | return 130 | } 131 | handler(value) 132 | } 133 | } 134 | } 135 | 136 | class TestEntityObservableToken: WrappedObservableToken { 137 | private let include: @Sendable (TestObject.RawData) -> Bool 138 | 139 | init( 140 | name: Notification.Name, 141 | include: @escaping @Sendable (TestObject.RawData) -> Bool = { _ in true } 142 | ) { 143 | self.include = include 144 | super.init(name: name, transform: { $0.object as? Parameter }) 145 | } 146 | 147 | override func observe(handler: @escaping @Sendable @MainActor (TestObject.RawData) -> Void) { 148 | let include = self.include 149 | super.observe { data in 150 | guard include(data) else { 151 | return 152 | } 153 | handler(data) 154 | } 155 | } 156 | } 157 | 158 | class VoidNotificationObservableToken: WrappedObservableToken { 159 | init(name: Notification.Name) { 160 | super.init(name: name, transform: { _ in () }) 161 | } 162 | } 163 | 164 | // MARK: - Associations 165 | 166 | extension TestObject { 167 | static func fetch(byIDs ids: [TestObject.ID]) -> [TestObject] { 168 | ids.map { TestObject(id: $0) } 169 | } 170 | } 171 | 172 | extension FetchRequestAssociation where FetchedObject == TestObject { 173 | convenience init( 174 | for associatedType: AssociatedType.Type, 175 | keyPath: EntityKeyPath, 176 | request: @escaping AssocationRequestByID 177 | ) { 178 | self.init( 179 | for: associatedType, 180 | keyPath: keyPath, 181 | request: request, 182 | creationTokenGenerator: { objectID in 183 | TestEntityObservableToken( 184 | name: AssociatedType.objectWasCreated(), 185 | include: { rawData in 186 | guard let includeID = AssociatedType.entityID(from: rawData) else { 187 | return false 188 | } 189 | return objectID == includeID 190 | } 191 | ) 192 | }, 193 | preferExistingValueOnCreate: true 194 | ) 195 | } 196 | 197 | convenience init( 198 | for associatedType: [AssociatedType].Type, 199 | keyPath: EntityKeyPath<[AssociatedType.ID]?>, 200 | request: @escaping AssocationRequestByID 201 | ) { 202 | self.init( 203 | for: associatedType, 204 | keyPath: keyPath, 205 | request: request, 206 | creationTokenGenerator: { objectIDs in 207 | TestEntityObservableToken( 208 | name: AssociatedType.objectWasCreated(), 209 | include: { rawData in 210 | guard let objectID = AssociatedType.entityID(from: rawData) else { 211 | return false 212 | } 213 | return objectIDs.contains(objectID) 214 | } 215 | ) 216 | }, 217 | creationObserved: { lhs, rhs in 218 | let lhs = lhs ?? [] 219 | guard let objectID = AssociatedType.entityID(from: rhs) else { 220 | return .same 221 | } 222 | if lhs.contains(where: { $0.id == objectID }) { 223 | return .same 224 | } 225 | return .invalid 226 | } 227 | ) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Controller/FetchedResultsControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchedResultsControllerProtocol.swift 3 | // Crew 4 | // 5 | // Created by Adam Lickel on 4/5/17. 6 | // Copyright © 2017 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | internal protocol InternalFetchResultsControllerProtocol: FetchedResultsControllerProtocol { 13 | @MainActor 14 | func manuallyInsert(objects: [FetchedObject], emitChanges: Bool) 15 | } 16 | 17 | public protocol DoublyObservableObject: ObservableObject { 18 | associatedtype ObjectDidChangePublisher: Publisher where ObjectWillChangePublisher.Output == ObjectDidChangePublisher.Output, ObjectWillChangePublisher.Failure == ObjectDidChangePublisher.Failure 19 | 20 | var objectDidChange: ObjectDidChangePublisher { get } 21 | } 22 | 23 | public protocol FetchedResultsControllerProtocol: DoublyObservableObject { 24 | associatedtype FetchedObject: FetchableObject 25 | 26 | typealias SectionNameKeyPath = FetchedResultsController.SectionNameKeyPath 27 | typealias Section = FetchedResultsSection 28 | 29 | var definition: FetchDefinition { get } 30 | 31 | /// Has performFetch() completed? 32 | var hasFetchedObjects: Bool { get } 33 | var sections: [Section] { get } 34 | var fetchedObjects: [FetchedObject] { get } 35 | 36 | var associatedFetchSize: Int { get set } 37 | 38 | var sectionNameKeyPath: SectionNameKeyPath? { get } 39 | var sortDescriptors: [NSSortDescriptor] { get } 40 | 41 | @MainActor 42 | func performFetch( 43 | completion: @escaping @MainActor @Sendable () -> Void 44 | ) 45 | @MainActor 46 | func resort( 47 | using newSortDescriptors: [NSSortDescriptor], 48 | completion: @escaping @MainActor @Sendable () -> Void 49 | ) 50 | @MainActor 51 | func reset() 52 | 53 | func indexPath(for object: FetchedObject) -> IndexPath? 54 | } 55 | 56 | // MARK: - Async 57 | 58 | public extension FetchedResultsControllerProtocol { 59 | @MainActor 60 | func performFetch() async { 61 | await withCheckedContinuation { continuation in 62 | performFetch { 63 | continuation.resume() 64 | } 65 | } 66 | } 67 | 68 | @MainActor 69 | func resort(using newSortDescriptors: [NSSortDescriptor]) async { 70 | await withCheckedContinuation { continuation in 71 | resort(using: sortDescriptors) { 72 | continuation.resume() 73 | } 74 | } 75 | } 76 | } 77 | 78 | // MARK: - Index Paths 79 | 80 | public extension FetchedResultsControllerProtocol { 81 | @MainActor 82 | func performFetch() { 83 | performFetch(completion: {}) 84 | } 85 | 86 | @MainActor 87 | func resort(using newSortDescriptors: [NSSortDescriptor]) { 88 | resort(using: newSortDescriptors, completion: {}) 89 | } 90 | 91 | internal func idealSectionIndex(forSectionName name: String) -> Int { 92 | guard let descriptor = sortDescriptors.first, sectionNameKeyPath != nil else { 93 | return 0 94 | } 95 | 96 | return sections.binarySearch { 97 | if descriptor.ascending { 98 | $0.name < name 99 | } else { 100 | $0.name > name 101 | } 102 | } 103 | } 104 | 105 | func idealObjectIndex(for object: FetchedObject, inArray array: [FetchedObject]) -> Int { 106 | guard !sortDescriptors.isEmpty else { 107 | return array.endIndex 108 | } 109 | 110 | let comparator = sortDescriptors.comparator 111 | return array.binarySearch { 112 | comparator($0, object) == .orderedAscending 113 | } 114 | } 115 | 116 | func object(at indexPath: IndexPath) -> FetchedObject { 117 | sections[indexPath.section].objects[indexPath.item] 118 | } 119 | 120 | func indexPath(for object: FetchedObject) -> IndexPath? { 121 | guard !sections.isEmpty else { 122 | return nil 123 | } 124 | 125 | let sectionName = object.sectionName(forKeyPath: sectionNameKeyPath) 126 | let sectionIndex = idealSectionIndex(forSectionName: sectionName) 127 | 128 | guard sectionIndex < sections.count else { 129 | return nil 130 | } 131 | guard let itemIndex = sections[sectionIndex].objects.firstIndex(of: object) else { 132 | return nil 133 | } 134 | 135 | return IndexPath(item: itemIndex, section: sectionIndex) 136 | } 137 | 138 | func indexPath(forObjectMatching matching: (FetchedObject) -> Bool) -> IndexPath? { 139 | for object in fetchedObjects where matching(object) { 140 | return indexPath(for: object) 141 | } 142 | return nil 143 | } 144 | 145 | internal func fetchIndex(for indexPath: IndexPath) -> Int? { 146 | guard !sections.isEmpty else { 147 | return nil 148 | } 149 | 150 | let sectionPrefix = sections[0 ..< indexPath.section].reduce(0) { $0 + $1.numberOfObjects } 151 | 152 | return sectionPrefix + indexPath.item 153 | } 154 | 155 | internal func indexPath(forFetchIndex fetchIndex: Int) -> IndexPath? { 156 | guard !sections.isEmpty, fetchIndex < fetchedObjects.count else { 157 | return nil 158 | } 159 | 160 | var sectionIndex = 0 161 | var objectIndex = fetchIndex 162 | 163 | while sectionIndex < sections.count { 164 | defer { 165 | sectionIndex += 1 166 | } 167 | 168 | let section = sections[sectionIndex] 169 | guard objectIndex >= section.numberOfObjects else { 170 | return IndexPath(item: objectIndex, section: sectionIndex) 171 | } 172 | 173 | objectIndex -= section.numberOfObjects 174 | } 175 | 176 | return nil 177 | } 178 | } 179 | 180 | // MARK: - Index Path Convenience methods 181 | 182 | public extension FetchedResultsControllerProtocol { 183 | func getIndexPath(before indexPath: IndexPath) -> IndexPath? { 184 | guard 0 ..< sections.count ~= indexPath.section else { 185 | return nil 186 | } 187 | 188 | var section = indexPath.section 189 | var item = indexPath.item - 1 190 | guard item < 0 else { 191 | return IndexPath(item: item, section: section) 192 | } 193 | 194 | section -= 1 195 | guard section >= 0 else { 196 | return nil 197 | } 198 | 199 | item = sections[section].numberOfObjects - 1 200 | 201 | return IndexPath(item: item, section: section) 202 | } 203 | 204 | func getIndexPath(after indexPath: IndexPath) -> IndexPath? { 205 | guard 0 ..< sections.count ~= indexPath.section else { 206 | return nil 207 | } 208 | 209 | var section = indexPath.section 210 | var item = indexPath.item + 1 211 | guard item == sections[section].numberOfObjects else { 212 | return IndexPath(item: item, section: section) 213 | } 214 | 215 | section += 1 216 | item = 0 217 | 218 | if section == sections.count { 219 | return nil 220 | } else { 221 | return IndexPath(item: item, section: section) 222 | } 223 | } 224 | } 225 | 226 | // MARK: - Binary Search 227 | 228 | extension RandomAccessCollection where Index: Strideable { 229 | func binarySearch(matching: (Element) -> Bool) -> Self.Index { 230 | var lowerIndex = startIndex 231 | var upperIndex = endIndex 232 | 233 | while lowerIndex != upperIndex { 234 | let middleIndex = index(lowerIndex, offsetBy: distance(from: lowerIndex, to: upperIndex) / 2) 235 | if matching(self[middleIndex]) { 236 | lowerIndex = index(after: middleIndex) 237 | } else { 238 | upperIndex = middleIndex 239 | } 240 | } 241 | return lowerIndex 242 | } 243 | } 244 | 245 | // MARK: - Section Names 246 | 247 | extension FetchableObjectProtocol where Self: NSObject { 248 | func sectionName(forKeyPath keyPath: KeyPath?) -> String { 249 | guard let keyPath else { 250 | return "" 251 | } 252 | return self[keyPath: keyPath] 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /FetchRequests/Tests/Controllers/PausableFetchedResultsControllerTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PausableFetchedResultsControllerTestCase.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 9/27/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FetchRequests 11 | 12 | class PausableFetchedResultsControllerTestCase: XCTestCase, FetchedResultsControllerTestHarness, @unchecked Sendable { 13 | // swiftlint:disable implicitly_unwrapped_optional test_case_accessibility 14 | 15 | private(set) var controller: PausableFetchedResultsController! 16 | 17 | private(set) var fetchCompletion: (([TestObject]) -> Void)! 18 | 19 | private var associationRequest: TestObject.AssociationRequest! 20 | 21 | // swiftlint:enable implicitly_unwrapped_optional test_case_accessibility 22 | 23 | private var inclusionCheck: ((TestObject.RawData) -> Bool)? 24 | 25 | private var changeEvents: [(change: FetchedResultsChange, object: TestObject)] = [] 26 | 27 | private func createFetchDefinition( 28 | associations: [PartialKeyPath] = [] 29 | ) -> FetchDefinition { 30 | let request: FetchDefinition.Request = { [unowned self] completion in 31 | self.fetchCompletion = completion 32 | } 33 | 34 | let desiredAssociations = TestObject.fetchRequestAssociations( 35 | matching: associations 36 | ) { [unowned self] associationRequest in 37 | self.associationRequest = associationRequest 38 | } 39 | 40 | let inclusionCheck: FetchDefinition.CreationInclusionCheck = { [unowned self] rawData in 41 | self.inclusionCheck?(rawData) ?? true 42 | } 43 | 44 | return FetchDefinition( 45 | request: request, 46 | creationInclusionCheck: inclusionCheck, 47 | associations: desiredAssociations 48 | ) 49 | } 50 | 51 | override func setUp() { 52 | super.setUp() 53 | 54 | cleanup() 55 | } 56 | 57 | override func tearDown() { 58 | super.tearDown() 59 | 60 | cleanup() 61 | } 62 | 63 | private func cleanup() { 64 | controller = nil 65 | fetchCompletion = nil 66 | associationRequest = nil 67 | inclusionCheck = nil 68 | } 69 | 70 | @MainActor 71 | func testBasicFetch() throws { 72 | controller = PausableFetchedResultsController( 73 | definition: createFetchDefinition(), 74 | debounceInsertsAndReloads: false 75 | ) 76 | 77 | let objectIDs = ["a", "b", "c"] 78 | 79 | try performFetch(objectIDs) 80 | 81 | XCTAssertEqual(controller.sections.count, 1) 82 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs) 83 | } 84 | 85 | @MainActor 86 | func testResort() throws { 87 | controller = PausableFetchedResultsController( 88 | definition: createFetchDefinition(), 89 | debounceInsertsAndReloads: false 90 | ) 91 | 92 | let objectIDs = ["a", "b", "c"] 93 | 94 | try performFetch(objectIDs) 95 | 96 | controller.resort(using: [NSSortDescriptor(keyPath: \TestObject.id, ascending: false)]) 97 | 98 | XCTAssertEqual(controller.sections.count, 1) 99 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs.reversed()) 100 | } 101 | 102 | @MainActor 103 | func testExpectInsertFromBroadcastNotification() throws { 104 | controller = PausableFetchedResultsController( 105 | definition: createFetchDefinition(), 106 | debounceInsertsAndReloads: false 107 | ) 108 | controller.setDelegate(self) 109 | 110 | let initialObjects = ["a", "b", "c"].compactMap { TestObject(id: $0) } 111 | 112 | try performFetch(initialObjects) 113 | 114 | fetchCompletion = nil 115 | changeEvents.removeAll() 116 | 117 | // Broadcast an update event & expect an insert to occur 118 | 119 | let newObject = TestObject(id: "d") 120 | 121 | let notification = Notification(name: TestObject.objectWasCreated(), object: newObject.data) 122 | NotificationCenter.default.post(notification) 123 | 124 | XCTAssertNil(fetchCompletion) 125 | 126 | XCTAssertEqual(changeEvents.count, 1) 127 | XCTAssertEqual(changeEvents[0].change, FetchedResultsChange.insert(location: IndexPath(item: 3, section: 0))) 128 | XCTAssertEqual(changeEvents[0].object.id, "d") 129 | 130 | changeEvents.removeAll() 131 | 132 | // Broadcast an update event & expect an insert won't occur 133 | 134 | NotificationCenter.default.post(notification) 135 | 136 | XCTAssertNil(fetchCompletion) 137 | XCTAssert(changeEvents.isEmpty) 138 | } 139 | 140 | @MainActor 141 | func testExpectPausedInsertFromBroadcastNotification() throws { 142 | controller = PausableFetchedResultsController( 143 | definition: createFetchDefinition(), 144 | debounceInsertsAndReloads: false 145 | ) 146 | controller.setDelegate(self) 147 | 148 | let initialObjects = ["a", "b", "c"].compactMap { TestObject(id: $0) } 149 | 150 | try performFetch(initialObjects) 151 | 152 | fetchCompletion = nil 153 | changeEvents.removeAll() 154 | 155 | // Pause fetch controller 156 | 157 | controller.isPaused = true 158 | 159 | // Broadcast an update event & don't expect an insert to occur 160 | 161 | let newObject = TestObject(id: "d") 162 | 163 | let notification = Notification(name: TestObject.objectWasCreated(), object: newObject.data) 164 | NotificationCenter.default.post(notification) 165 | 166 | XCTAssertNil(fetchCompletion) 167 | 168 | XCTAssertEqual(controller.fetchedObjects, initialObjects) 169 | XCTAssertEqual(changeEvents.count, 0) 170 | 171 | let pausedIndexPath = controller.indexPath(for: newObject) 172 | XCTAssertNil(pausedIndexPath) 173 | 174 | // Unpause and don't expect an insert *event* to occur, but to be updated 175 | 176 | controller.isPaused = false 177 | 178 | XCTAssertNil(fetchCompletion) 179 | 180 | let unpausedIndexPath = controller.indexPath(for: newObject) 181 | XCTAssertEqual(unpausedIndexPath, IndexPath(item: 3, section: 0)) 182 | 183 | XCTAssertEqual(controller.sections[0].fetchedIDs, ["a", "b", "c", "d"]) 184 | XCTAssertEqual(changeEvents.count, 0) 185 | } 186 | 187 | @MainActor 188 | func testResetClearsPaused() throws { 189 | controller = PausableFetchedResultsController( 190 | definition: createFetchDefinition(), 191 | debounceInsertsAndReloads: false 192 | ) 193 | 194 | let objectIDs = ["a", "b", "c"] 195 | 196 | try performFetch(objectIDs) 197 | 198 | controller.isPaused = true 199 | 200 | controller.reset() 201 | 202 | XCTAssertFalse(controller.isPaused) 203 | } 204 | 205 | @MainActor 206 | func testWrappedProperties() throws { 207 | let fetchDefinition = createFetchDefinition() 208 | 209 | controller = PausableFetchedResultsController( 210 | definition: fetchDefinition, 211 | sectionNameKeyPath: \TestObject.sectionName, 212 | debounceInsertsAndReloads: false 213 | ) 214 | 215 | let effectiveSortDescriptorKeys = [ 216 | #selector(getter: TestObject.sectionName), 217 | NSSelectorFromString("self"), 218 | ].map(\.description) 219 | 220 | try performFetch(["a", "b", "c"]) 221 | 222 | controller.associatedFetchSize = 20 223 | 224 | XCTAssert(controller.definition === fetchDefinition) 225 | XCTAssertEqual(controller.sortDescriptors.map(\.key), effectiveSortDescriptorKeys) 226 | XCTAssertEqual(controller.sectionNameKeyPath, \TestObject.sectionName) 227 | XCTAssertEqual(controller.associatedFetchSize, 20) 228 | XCTAssertTrue(controller.hasFetchedObjects) 229 | } 230 | } 231 | 232 | // MARK: - Paginating 233 | 234 | extension PausableFetchedResultsControllerTestCase { 235 | @MainActor 236 | func testCanCreatePausableVariation() throws { 237 | let baseDefinition = createFetchDefinition() 238 | 239 | var paginationRequests = 0 240 | 241 | let fetchDefinition = PaginatingFetchDefinition( 242 | request: baseDefinition.request, 243 | paginationRequest: { current, completion in 244 | paginationRequests += 1 245 | 246 | let newObject = TestObject(id: paginationRequests.description) 247 | 248 | completion([newObject]) 249 | }, 250 | creationInclusionCheck: baseDefinition.creationInclusionCheck, 251 | associations: baseDefinition.associations 252 | ) 253 | 254 | let controller = PausablePaginatingFetchedResultsController( 255 | definition: fetchDefinition, 256 | sectionNameKeyPath: \TestObject.sectionName, 257 | debounceInsertsAndReloads: false 258 | ) 259 | self.controller = controller 260 | controller.setDelegate(self) 261 | 262 | let objectIDs = ["a", "b", "c"] 263 | 264 | try performFetch(objectIDs) 265 | 266 | controller.isPaused = true 267 | changeEvents.removeAll() 268 | 269 | controller.performPagination() 270 | 271 | XCTAssertEqual(controller.sections[0].fetchedIDs, ["a", "b", "c"]) 272 | 273 | controller.isPaused = false 274 | 275 | XCTAssertTrue(changeEvents.isEmpty) 276 | XCTAssertEqual(controller.sections[0].fetchedIDs, ["a", "b", "c", "1"]) 277 | } 278 | } 279 | 280 | // MARK: - FetchedResultsControllerDelegate 281 | 282 | extension PausableFetchedResultsControllerTestCase: PausableFetchedResultsControllerDelegate { 283 | func controller( 284 | _ controller: PausableFetchedResultsController, 285 | didChange object: TestObject, 286 | for change: FetchedResultsChange 287 | ) { 288 | changeEvents.append((change: change, object: object)) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /FetchRequests/Tests/Controllers/PaginatingFetchedResultsControllerTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaginatingFetchedResultsControllerTestCase.swift 3 | // FetchRequests-iOSTests 4 | // 5 | // Created by Adam Lickel on 9/27/18. 6 | // Copyright © 2018 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FetchRequests 11 | 12 | class PaginatingFetchedResultsControllerTestCase: XCTestCase, FetchedResultsControllerTestHarness, @unchecked Sendable { 13 | // swiftlint:disable implicitly_unwrapped_optional test_case_accessibility 14 | 15 | private(set) var controller: PaginatingFetchedResultsController! 16 | 17 | private(set) var fetchCompletion: (([TestObject]) -> Void)! 18 | 19 | private var paginationCurrentResults: [TestObject]! 20 | private var paginationRequestCompletion: (([TestObject]?) -> Void)! 21 | 22 | private var performPaginationCompletionResult: Bool! 23 | 24 | private var associationRequest: TestObject.AssociationRequest! 25 | 26 | // swiftlint:enable implicitly_unwrapped_optional test_case_accessibility 27 | 28 | private var inclusionCheck: ((TestObject.RawData) -> Bool)? 29 | 30 | private var changeEvents: [(change: FetchedResultsChange, object: TestObject)] = [] 31 | 32 | private func createFetchDefinition( 33 | associations: [PartialKeyPath] = [] 34 | ) -> PaginatingFetchDefinition { 35 | let request: PaginatingFetchDefinition.Request = { [unowned self] completion in 36 | self.fetchCompletion = completion 37 | } 38 | let paginationRequest: PaginatingFetchDefinition.PaginationRequest = { [unowned self] currentResults, completion in 39 | self.paginationCurrentResults = currentResults 40 | self.paginationRequestCompletion = completion 41 | } 42 | 43 | let desiredAssociations = TestObject.fetchRequestAssociations( 44 | matching: associations 45 | ) { [unowned self] associationRequest in 46 | self.associationRequest = associationRequest 47 | } 48 | 49 | let inclusionCheck: PaginatingFetchDefinition.CreationInclusionCheck = { [unowned self] rawData in 50 | self.inclusionCheck?(rawData) ?? true 51 | } 52 | 53 | return PaginatingFetchDefinition( 54 | request: request, 55 | paginationRequest: paginationRequest, 56 | creationInclusionCheck: inclusionCheck, 57 | associations: desiredAssociations 58 | ) 59 | } 60 | 61 | override func setUp() { 62 | super.setUp() 63 | 64 | cleanup() 65 | } 66 | 67 | override func tearDown() { 68 | super.tearDown() 69 | 70 | cleanup() 71 | } 72 | 73 | private func cleanup() { 74 | controller = nil 75 | fetchCompletion = nil 76 | paginationCurrentResults = nil 77 | paginationRequestCompletion = nil 78 | performPaginationCompletionResult = nil 79 | associationRequest = nil 80 | inclusionCheck = nil 81 | 82 | changeEvents = [] 83 | } 84 | 85 | @MainActor 86 | func testBasicFetch() throws { 87 | controller = PaginatingFetchedResultsController( 88 | definition: createFetchDefinition(), 89 | debounceInsertsAndReloads: false 90 | ) 91 | 92 | let objectIDs = ["a", "b", "c"] 93 | 94 | try performFetch(objectIDs) 95 | 96 | XCTAssertEqual(controller.sections.count, 1) 97 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs) 98 | } 99 | 100 | @MainActor 101 | func testResort() throws { 102 | controller = PaginatingFetchedResultsController( 103 | definition: createFetchDefinition(), 104 | debounceInsertsAndReloads: false 105 | ) 106 | 107 | let objectIDs = ["a", "b", "c"] 108 | 109 | try performFetch(objectIDs) 110 | 111 | controller.resort(using: [NSSortDescriptor(keyPath: \TestObject.id, ascending: false)]) 112 | 113 | XCTAssertEqual(controller.sections.count, 1) 114 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs.reversed()) 115 | } 116 | 117 | @MainActor 118 | func testPaginationTriggersLoad() throws { 119 | controller = PaginatingFetchedResultsController( 120 | definition: createFetchDefinition(), 121 | debounceInsertsAndReloads: false 122 | ) 123 | controller.setDelegate(self) 124 | 125 | // Fetch some objects 126 | 127 | let objectIDs = ["a", "b", "c"] 128 | 129 | try performFetch(objectIDs) 130 | 131 | fetchCompletion = nil 132 | paginationCurrentResults = nil 133 | paginationRequestCompletion = nil 134 | performPaginationCompletionResult = nil 135 | changeEvents.removeAll() 136 | 137 | // Trigger pagination 138 | 139 | let paginationObjectIDs = ["d", "f"] 140 | 141 | performPagination(paginationObjectIDs) 142 | 143 | XCTAssertTrue(performPaginationCompletionResult) 144 | 145 | XCTAssertEqual(controller.sections.count, 1) 146 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs + paginationObjectIDs) 147 | 148 | XCTAssertEqual(changeEvents.count, 2) 149 | XCTAssertEqual(changeEvents[0].change, FetchedResultsChange.insert(location: IndexPath(item: 3, section: 0))) 150 | XCTAssertEqual(changeEvents[0].object.id, "d") 151 | XCTAssertEqual(changeEvents[1].change, FetchedResultsChange.insert(location: IndexPath(item: 4, section: 0))) 152 | XCTAssertEqual(changeEvents[1].object.id, "f") 153 | } 154 | 155 | @MainActor 156 | func testPaginationDoesNotDisableInserts() throws { 157 | controller = PaginatingFetchedResultsController( 158 | definition: createFetchDefinition(), 159 | sortDescriptors: [ 160 | NSSortDescriptor(keyPath: \FetchedObject.id, ascending: true), 161 | ], 162 | debounceInsertsAndReloads: false 163 | ) 164 | controller.setDelegate(self) 165 | 166 | // Fetch some objects 167 | 168 | let objectIDs = ["a", "b", "c"] 169 | 170 | try performFetch(objectIDs) 171 | 172 | fetchCompletion = nil 173 | paginationCurrentResults = nil 174 | paginationRequestCompletion = nil 175 | performPaginationCompletionResult = nil 176 | changeEvents.removeAll() 177 | 178 | // Trigger pagination 179 | 180 | let paginationObjectIDs = ["d", "f"] 181 | 182 | performPagination(paginationObjectIDs) 183 | 184 | XCTAssertTrue(performPaginationCompletionResult) 185 | 186 | fetchCompletion = nil 187 | paginationCurrentResults = nil 188 | paginationRequestCompletion = nil 189 | performPaginationCompletionResult = nil 190 | changeEvents.removeAll() 191 | 192 | // Trigger insert 193 | 194 | let newObject = TestObject(id: "e") 195 | 196 | let notification = Notification(name: TestObject.objectWasCreated(), object: newObject.data) 197 | NotificationCenter.default.post(notification) 198 | 199 | XCTAssertNil(fetchCompletion) 200 | XCTAssertNil(paginationCurrentResults) 201 | XCTAssertNil(paginationRequestCompletion) 202 | 203 | XCTAssertEqual(controller.sections.count, 1) 204 | XCTAssertEqual(controller.sections[0].fetchedIDs, ["a", "b", "c", "d", "e", "f"]) 205 | 206 | XCTAssertEqual(changeEvents.count, 1) 207 | XCTAssertEqual(changeEvents[0].change, FetchedResultsChange.insert(location: IndexPath(item: 4, section: 0))) 208 | XCTAssertEqual(changeEvents[0].object.id, "e") 209 | } 210 | 211 | @MainActor 212 | func testPaginationHasObjects() async throws { 213 | controller = PaginatingFetchedResultsController( 214 | definition: createFetchDefinition(), 215 | debounceInsertsAndReloads: false 216 | ) 217 | controller.setDelegate(self) 218 | 219 | // Fetch some objects 220 | 221 | let objectIDs = ["a", "b", "c"] 222 | 223 | try performFetch(objectIDs) 224 | 225 | fetchCompletion = nil 226 | paginationCurrentResults = nil 227 | paginationRequestCompletion = nil 228 | performPaginationCompletionResult = nil 229 | changeEvents.removeAll() 230 | 231 | // Trigger pagination 232 | 233 | let paginationObjectIDs = ["d", "f"] 234 | 235 | performPagination(paginationObjectIDs) 236 | 237 | XCTAssertTrue(performPaginationCompletionResult) 238 | 239 | XCTAssertEqual(controller.sections.count, 1) 240 | XCTAssertEqual(controller.sections[0].fetchedIDs, objectIDs + paginationObjectIDs) 241 | 242 | XCTAssertEqual(changeEvents.count, 2) 243 | XCTAssertEqual(changeEvents[0].change, FetchedResultsChange.insert(location: IndexPath(item: 3, section: 0))) 244 | XCTAssertEqual(changeEvents[0].object.id, "d") 245 | XCTAssertEqual(changeEvents[1].change, FetchedResultsChange.insert(location: IndexPath(item: 4, section: 0))) 246 | XCTAssertEqual(changeEvents[1].object.id, "f") 247 | } 248 | 249 | @MainActor 250 | func testPaginationDoesNotHaveObjects() async throws { 251 | controller = PaginatingFetchedResultsController( 252 | definition: createFetchDefinition(), 253 | debounceInsertsAndReloads: false 254 | ) 255 | controller.setDelegate(self) 256 | 257 | // Fetch some objects 258 | 259 | let objectIDs = ["a", "b", "c"] 260 | 261 | try performFetch(objectIDs) 262 | 263 | fetchCompletion = nil 264 | paginationCurrentResults = nil 265 | paginationRequestCompletion = nil 266 | performPaginationCompletionResult = nil 267 | changeEvents.removeAll() 268 | 269 | // Trigger pagination 270 | 271 | let paginationObjectIDs: [String] = [] 272 | 273 | performPagination(paginationObjectIDs) 274 | 275 | XCTAssertFalse(performPaginationCompletionResult) 276 | } 277 | } 278 | 279 | // MARK: - FetchedResultsControllerDelegate 280 | 281 | extension PaginatingFetchedResultsControllerTestCase: FetchedResultsControllerDelegate { 282 | func controller( 283 | _ controller: FetchedResultsController, 284 | didChange object: TestObject, 285 | for change: FetchedResultsChange 286 | ) { 287 | changeEvents.append((change: change, object: object)) 288 | } 289 | } 290 | 291 | // MARK: - Helper Functions 292 | 293 | private extension PaginatingFetchedResultsControllerTestCase { 294 | @MainActor 295 | func performPagination(_ objectIDs: [String], file: StaticString = #filePath, line: UInt = #line) { 296 | let objects = objectIDs.compactMap { TestObject(id: $0) } 297 | 298 | performPagination(objects, file: file, line: line) 299 | } 300 | 301 | @MainActor 302 | func performPagination(_ objects: [TestObject], file: StaticString = #filePath, line: UInt = #line) { 303 | controller.performPagination { hasPageResults in 304 | self.performPaginationCompletionResult = hasPageResults 305 | } 306 | 307 | self.paginationRequestCompletion(objects) 308 | 309 | let hasAllObjects = objects.allSatisfy { controller.fetchedObjects.contains($0) } 310 | XCTAssertTrue(hasAllObjects, file: file, line: line) 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Controller/PausableFetchedResultsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PausableFetchedResultsController.swift 3 | // Crew 4 | // 5 | // Created by Adam Lickel on 4/5/17. 6 | // Copyright © 2017 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | @MainActor 13 | public protocol PausableFetchedResultsControllerDelegate: AnyObject { 14 | associatedtype FetchedObject: FetchableObject 15 | 16 | func controllerWillChangeContent(_ controller: PausableFetchedResultsController) 17 | func controllerDidChangeContent(_ controller: PausableFetchedResultsController) 18 | 19 | func controller( 20 | _ controller: PausableFetchedResultsController, 21 | didChange object: FetchedObject, 22 | for change: FetchedResultsChange 23 | ) 24 | func controller( 25 | _ controller: PausableFetchedResultsController, 26 | didChange section: FetchedResultsSection, 27 | for change: FetchedResultsChange 28 | ) 29 | } 30 | 31 | public extension PausableFetchedResultsControllerDelegate { 32 | func controllerWillChangeContent(_ controller: PausableFetchedResultsController) {} 33 | func controllerDidChangeContent(_ controller: PausableFetchedResultsController) {} 34 | 35 | func controller( 36 | _ controller: PausableFetchedResultsController, 37 | didChange object: FetchedObject, 38 | for change: FetchedResultsChange 39 | ) { 40 | } 41 | 42 | func controller( 43 | _ controller: PausableFetchedResultsController, 44 | didChange section: FetchedResultsSection, 45 | for change: FetchedResultsChange 46 | ) { 47 | } 48 | } 49 | 50 | public class PausableFetchedResultsController { 51 | public typealias Delegate = PausableFetchedResultsControllerDelegate 52 | public typealias Section = FetchedResultsSection 53 | public typealias SectionNameKeyPath = FetchedResultsController.SectionNameKeyPath 54 | 55 | private let controller: FetchedResultsController 56 | 57 | private var hasFetchedObjectsSnapshot: Bool? 58 | private var fetchedObjectsSnapshot: [FetchedObject]? 59 | private var sectionsSnapshot: [Section]? 60 | 61 | fileprivate let objectWillChangeSubject = PassthroughSubject() 62 | fileprivate let objectDidChangeSubject = PassthroughSubject() 63 | 64 | public private(set) lazy var objectWillChange = objectWillChangeSubject.eraseToAnyPublisher() 65 | public private(set) lazy var objectDidChange = objectDidChangeSubject.eraseToAnyPublisher() 66 | 67 | /// Pause all eventing and return a snapshot of the value 68 | /// 69 | /// Pausing and unpausing will never trigger the delegate. While paused, the delegate will not fire for any reason. 70 | /// If you depend upon your delegate for eventing, you will need to reload any dependencies manually. 71 | /// The Publishers will trigger whenever the value of isPaused changes. 72 | public var isPaused: Bool = false { 73 | didSet { 74 | guard oldValue != isPaused else { 75 | return 76 | } 77 | 78 | objectWillChangeSubject.send() 79 | 80 | if isPaused { 81 | hasFetchedObjectsSnapshot = controller.hasFetchedObjects 82 | sectionsSnapshot = controller.sections 83 | fetchedObjectsSnapshot = controller.fetchedObjects 84 | } else { 85 | hasFetchedObjectsSnapshot = nil 86 | sectionsSnapshot = nil 87 | fetchedObjectsSnapshot = nil 88 | } 89 | 90 | objectDidChangeSubject.send() 91 | } 92 | } 93 | 94 | // swiftlint:disable:next weak_delegate 95 | private var delegate: DelegateThunk? 96 | 97 | public init( 98 | definition: FetchDefinition, 99 | sortDescriptors: [NSSortDescriptor] = [], 100 | sectionNameKeyPath: SectionNameKeyPath? = nil, 101 | debounceInsertsAndReloads: Bool = true 102 | ) { 103 | controller = FetchedResultsController( 104 | definition: definition, 105 | sortDescriptors: sortDescriptors, 106 | sectionNameKeyPath: sectionNameKeyPath, 107 | debounceInsertsAndReloads: debounceInsertsAndReloads 108 | ) 109 | } 110 | 111 | // MARK: - Delegate 112 | 113 | public func setDelegate(_ delegate: (some Delegate)?) { 114 | self.delegate = delegate.flatMap { 115 | DelegateThunk($0, pausableController: self) 116 | } 117 | controller.setDelegate(self.delegate) 118 | } 119 | 120 | public func clearDelegate() { 121 | delegate = nil 122 | controller.clearDelegate() 123 | } 124 | } 125 | 126 | // MARK: - Wrapper Functions 127 | 128 | extension PausableFetchedResultsController: FetchedResultsControllerProtocol { 129 | @MainActor 130 | public func performFetch( 131 | completion: @escaping @MainActor @Sendable () -> Void 132 | ) { 133 | controller.performFetch(completion: completion) 134 | } 135 | 136 | @MainActor 137 | public func resort( 138 | using newSortDescriptors: [NSSortDescriptor], 139 | completion: @escaping @MainActor @Sendable () -> Void 140 | ) { 141 | controller.resort(using: newSortDescriptors, completion: completion) 142 | } 143 | 144 | @MainActor 145 | public func reset() { 146 | controller.reset() 147 | isPaused = false 148 | } 149 | 150 | public var definition: FetchDefinition { 151 | controller.definition 152 | } 153 | 154 | public var sortDescriptors: [NSSortDescriptor] { 155 | controller.sortDescriptors 156 | } 157 | 158 | public var sectionNameKeyPath: SectionNameKeyPath? { 159 | controller.sectionNameKeyPath 160 | } 161 | 162 | public var associatedFetchSize: Int { 163 | get { 164 | controller.associatedFetchSize 165 | } 166 | set { 167 | controller.associatedFetchSize = newValue 168 | } 169 | } 170 | 171 | public var hasFetchedObjects: Bool { 172 | hasFetchedObjectsSnapshot ?? controller.hasFetchedObjects 173 | } 174 | 175 | public var fetchedObjects: [FetchedObject] { 176 | fetchedObjectsSnapshot ?? controller.fetchedObjects 177 | } 178 | 179 | public var sections: [Section] { 180 | sectionsSnapshot ?? controller.sections 181 | } 182 | 183 | internal var debounceInsertsAndReloads: Bool { 184 | controller.debounceInsertsAndReloads 185 | } 186 | } 187 | 188 | // MARK: - InternalFetchResultsControllerProtocol 189 | 190 | extension PausableFetchedResultsController: InternalFetchResultsControllerProtocol { 191 | internal func manuallyInsert(objects: [FetchedObject], emitChanges: Bool = true) { 192 | controller.manuallyInsert(objects: objects, emitChanges: emitChanges) 193 | } 194 | } 195 | 196 | // MARK: - DelegateThunk 197 | 198 | private class DelegateThunk { 199 | typealias Parent = PausableFetchedResultsControllerDelegate 200 | typealias ParentController = FetchedResultsController 201 | typealias PausableController = PausableFetchedResultsController 202 | typealias Section = FetchedResultsSection 203 | 204 | private weak var parent: (any Parent)? 205 | private weak var pausableController: PausableController? 206 | 207 | #if compiler(<6) 208 | private let willChange: @MainActor (_ controller: PausableController) -> Void 209 | private let didChange: @MainActor (_ controller: PausableController) -> Void 210 | 211 | private let changeObject: @MainActor (_ controller: PausableController, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void 212 | private let changeSection: @MainActor (_ controller: PausableController, _ section: Section, _ change: FetchedResultsChange) -> Void 213 | #endif 214 | 215 | init(_ parent: some Parent, pausableController: PausableController) { 216 | self.parent = parent 217 | self.pausableController = pausableController 218 | 219 | #if compiler(<6) 220 | 221 | willChange = { [weak parent] controller in 222 | parent?.controllerWillChangeContent(controller) 223 | } 224 | didChange = { [weak parent] controller in 225 | parent?.controllerDidChangeContent(controller) 226 | } 227 | 228 | changeObject = { [weak parent] controller, object, change in 229 | parent?.controller(controller, didChange: object, for: change) 230 | } 231 | changeSection = { [weak parent] controller, section, change in 232 | parent?.controller(controller, didChange: section, for: change) 233 | } 234 | #endif 235 | } 236 | } 237 | 238 | extension DelegateThunk: FetchedResultsControllerDelegate { 239 | func controllerWillChangeContent(_ controller: ParentController) { 240 | guard let pausableController, !pausableController.isPaused else { 241 | return 242 | } 243 | 244 | pausableController.objectWillChangeSubject.send() 245 | self.controllerWillChangeContent(pausableController) 246 | } 247 | 248 | func controllerDidChangeContent(_ controller: ParentController) { 249 | guard let pausableController, !pausableController.isPaused else { 250 | return 251 | } 252 | 253 | self.controllerDidChangeContent(pausableController) 254 | pausableController.objectDidChangeSubject.send() 255 | } 256 | 257 | func controller( 258 | _ controller: ParentController, 259 | didChange object: FetchedObject, 260 | for change: FetchedResultsChange 261 | ) { 262 | guard let pausableController, !pausableController.isPaused else { 263 | return 264 | } 265 | 266 | self.controller(pausableController, didChange: object, for: change) 267 | } 268 | 269 | func controller( 270 | _ controller: ParentController, 271 | didChange section: Section, 272 | for change: FetchedResultsChange 273 | ) { 274 | guard let pausableController, !pausableController.isPaused else { 275 | return 276 | } 277 | 278 | self.controller(pausableController, didChange: section, for: change) 279 | } 280 | } 281 | 282 | extension DelegateThunk: PausableFetchedResultsControllerDelegate { 283 | public func controllerWillChangeContent(_ controller: PausableController) { 284 | #if compiler(>=6) 285 | self.parent?.controllerWillChangeContent(controller) 286 | #else 287 | self.willChange(controller) 288 | #endif 289 | } 290 | 291 | public func controllerDidChangeContent(_ controller: PausableController) { 292 | #if compiler(>=6) 293 | self.parent?.controllerDidChangeContent(controller) 294 | #else 295 | self.didChange(controller) 296 | #endif 297 | } 298 | 299 | public func controller( 300 | _ controller: PausableController, 301 | didChange object: FetchedObject, 302 | for change: FetchedResultsChange 303 | ) { 304 | #if compiler(>=6) 305 | self.parent?.controller(controller, didChange: object, for: change) 306 | #else 307 | self.changeObject(controller, object, change) 308 | #endif 309 | } 310 | 311 | public func controller( 312 | _ controller: PausableController, 313 | didChange section: FetchedResultsSection, 314 | for change: FetchedResultsChange 315 | ) { 316 | #if compiler(>=6) 317 | self.parent?.controller(controller, didChange: section, for: change) 318 | #else 319 | self.changeSection(controller, section, change) 320 | #endif 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /FetchRequests/Sources/Controller/CollapsibleSectionsFetchedResultsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollapsibleSectionsFetchedResultsController.swift 3 | // Crew 4 | // 5 | // Created by Adam Proschek on 2/10/17. 6 | // Copyright © 2017 Speramus Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct SectionCollapseConfig: Equatable { 12 | public let maxNumberOfItemsToDisplay: Int 13 | public let numberOfItemsToDisplayWhenExceedingMax: Int? 14 | 15 | public init(maxNumberOfItemsToDisplay: Int, whenExceedingMax: Int? = nil) { 16 | self.maxNumberOfItemsToDisplay = maxNumberOfItemsToDisplay 17 | self.numberOfItemsToDisplayWhenExceedingMax = whenExceedingMax 18 | } 19 | } 20 | 21 | @MainActor 22 | // swiftlint:disable:next type_name 23 | public protocol CollapsibleSectionsFetchedResultsControllerDelegate: AnyObject { 24 | associatedtype FetchedObject: FetchableObject 25 | 26 | func controllerWillChangeContent(_ controller: CollapsibleSectionsFetchedResultsController) 27 | func controllerDidChangeContent(_ controller: CollapsibleSectionsFetchedResultsController) 28 | 29 | func controller( 30 | _ controller: CollapsibleSectionsFetchedResultsController, 31 | didChange object: FetchedObject, 32 | for change: FetchedResultsChange 33 | ) 34 | func controller( 35 | _ controller: CollapsibleSectionsFetchedResultsController, 36 | didChange section: CollapsibleResultsSection, 37 | for change: FetchedResultsChange 38 | ) 39 | } 40 | 41 | public struct CollapsibleResultsSection: Equatable, Identifiable { 42 | fileprivate let section: FetchedResultsSection 43 | private let config: SectionCollapseConfig? 44 | public let isCollapsed: Bool 45 | 46 | let displayableObjects: [FetchedObject] 47 | 48 | public var allObjects: [FetchedObject] { 49 | section.objects 50 | } 51 | 52 | public var id: String { 53 | section.id 54 | } 55 | 56 | public var name: String { 57 | section.name 58 | } 59 | 60 | public var numberOfDisplayableObjects: Int { 61 | displayableObjects.count 62 | } 63 | 64 | init( 65 | section: FetchedResultsSection, 66 | collapsed: Bool, 67 | config: SectionCollapseConfig? = nil 68 | ) { 69 | self.section = section 70 | self.isCollapsed = collapsed 71 | self.config = config 72 | 73 | guard let config, collapsed else { 74 | displayableObjects = section.objects 75 | return 76 | } 77 | 78 | if let numberOfItemsToDisplayWhenExceedingMax = config.numberOfItemsToDisplayWhenExceedingMax, 79 | section.numberOfObjects > config.maxNumberOfItemsToDisplay 80 | { 81 | let collapsedObjects = section.objects.prefix(numberOfItemsToDisplayWhenExceedingMax) 82 | displayableObjects = Array(collapsedObjects) 83 | } else if section.numberOfObjects > config.maxNumberOfItemsToDisplay { 84 | let collapsedObjects = section.objects.prefix(config.maxNumberOfItemsToDisplay) 85 | displayableObjects = Array(collapsedObjects) 86 | } else { 87 | displayableObjects = section.objects 88 | } 89 | } 90 | } 91 | 92 | public class CollapsibleSectionsFetchedResultsController: NSObject { 93 | public typealias Delegate = CollapsibleSectionsFetchedResultsControllerDelegate 94 | 95 | public typealias BackingFetchController = FetchedResultsController 96 | public typealias Section = CollapsibleResultsSection 97 | public typealias SectionCollapseCheck = (_ section: BackingFetchController.Section) -> Bool 98 | public typealias SectionCollapseConfigCheck = (_ section: BackingFetchController.Section) -> SectionCollapseConfig? 99 | public typealias SectionNameKeyPath = FetchedResultsController.SectionNameKeyPath 100 | 101 | private var changedSectionsDuringContentChange: Set = [] 102 | private var deletedSectionsDuringContentChange: Set = [] 103 | private var previousSectionsDuringContentChange: [CollapsibleResultsSection] = [] 104 | 105 | // swiftlint:disable:next identifier_name 106 | private var collapsedSectionsModifiedDuringContentChange: Set = [] 107 | private var collapsedSectionNames: Set = [] 108 | 109 | private let fetchController: BackingFetchController 110 | private let initialSectionCollapseCheck: SectionCollapseCheck 111 | private var sectionNamesCheckedForInitialCollapse: Set = [] 112 | private let sectionConfigCheck: SectionCollapseConfigCheck 113 | 114 | // swiftlint:disable:next weak_delegate 115 | private var delegate: DelegateThunk? 116 | 117 | private var sectionConfigs: [String: SectionCollapseConfig] = [:] 118 | 119 | public var sections: [CollapsibleResultsSection] = [] 120 | public var definition: FetchDefinition { 121 | fetchController.definition 122 | } 123 | 124 | public var sortDescriptors: [NSSortDescriptor] { 125 | fetchController.sortDescriptors 126 | } 127 | 128 | public var fetchedObjects: [FetchedObject] { 129 | fetchController.fetchedObjects 130 | } 131 | 132 | public var hasFetchedObjects: Bool { 133 | fetchController.hasFetchedObjects 134 | } 135 | 136 | public var associatedFetchSize: Int { 137 | get { 138 | fetchController.associatedFetchSize 139 | } 140 | set { 141 | fetchController.associatedFetchSize = newValue 142 | } 143 | } 144 | 145 | public init( 146 | definition: FetchDefinition, 147 | sortDescriptors: [NSSortDescriptor] = [], 148 | sectionNameKeyPath: SectionNameKeyPath? = nil, 149 | debounceInsertsAndReloads: Bool = true, 150 | initialSectionCollapseCheck: @escaping SectionCollapseCheck = { _ in false }, 151 | sectionConfigCheck: @escaping SectionCollapseConfigCheck = { _ in nil } 152 | ) { 153 | fetchController = FetchedResultsController( 154 | definition: definition, 155 | sortDescriptors: sortDescriptors, 156 | sectionNameKeyPath: sectionNameKeyPath, 157 | debounceInsertsAndReloads: debounceInsertsAndReloads 158 | ) 159 | 160 | self.initialSectionCollapseCheck = initialSectionCollapseCheck 161 | self.sectionConfigCheck = sectionConfigCheck 162 | super.init() 163 | fetchController.setDelegate(self) 164 | } 165 | 166 | // MARK: - Delegate 167 | 168 | public func setDelegate(_ delegate: (some Delegate)?) { 169 | self.delegate = delegate.flatMap { 170 | DelegateThunk($0) 171 | } 172 | } 173 | 174 | public func clearDelegate() { 175 | delegate = nil 176 | } 177 | } 178 | 179 | // MARK: - Helper Methods 180 | public extension CollapsibleSectionsFetchedResultsController { 181 | @MainActor 182 | func update(section: CollapsibleResultsSection, maximumNumberOfItemsToDisplay max: Int, whenExceedingMax: Int? = nil) { 183 | guard let sectionIndex = sections.firstIndex(of: section) else { 184 | return 185 | } 186 | 187 | performChanges(onIndex: sectionIndex) { section in 188 | let config = SectionCollapseConfig(maxNumberOfItemsToDisplay: max, whenExceedingMax: whenExceedingMax) 189 | sectionConfigs[section.name] = config 190 | } 191 | } 192 | 193 | @MainActor 194 | func expand(section: Section) { 195 | guard let sectionIndex = sections.firstIndex(of: section) else { 196 | return 197 | } 198 | 199 | performChanges(onIndex: sectionIndex) { section in 200 | self.collapsedSectionNames.remove(section.name) 201 | } 202 | } 203 | 204 | @MainActor 205 | func collapse(section: Section) { 206 | guard let sectionIndex = sections.firstIndex(of: section) else { 207 | return 208 | } 209 | 210 | performChanges(onIndex: sectionIndex) { section in 211 | self.collapsedSectionNames.insert(section.name) 212 | } 213 | } 214 | 215 | @MainActor 216 | private func performChanges(onIndex sectionIndex: Int, changes: (Section) -> Void) { 217 | let section = sections[sectionIndex] 218 | controllerWillChangeContent(fetchController) 219 | changes(section) 220 | controller(fetchController, didChange: section.section, for: .update(location: sectionIndex)) 221 | controllerDidChangeContent(fetchController) 222 | } 223 | 224 | func object(at indexPath: IndexPath) -> FetchedObject { 225 | sections[indexPath.section].displayableObjects[indexPath.item] 226 | } 227 | 228 | func indexPath(for object: FetchedObject) -> IndexPath? { 229 | guard let indexPath = fetchController.indexPath(for: object) else { 230 | return nil 231 | } 232 | 233 | let section = sections[indexPath.section] 234 | let numberOfItemsDisplayed = section.numberOfDisplayableObjects 235 | return indexPath.item < numberOfItemsDisplayed ? indexPath : nil 236 | } 237 | 238 | private func updateSections() { 239 | sections = fetchController.sections.map(createSection) 240 | } 241 | 242 | private func updateSections(atIndices indices: Int...) { 243 | for index in indices { 244 | sections[index] = createSection(from: fetchController.sections[index]) 245 | } 246 | } 247 | 248 | private func createSection(from section: BackingFetchController.Section) -> Section { 249 | if !sectionNamesCheckedForInitialCollapse.contains(section.name) { 250 | if initialSectionCollapseCheck(section) { 251 | collapsedSectionNames.insert(section.name) 252 | } 253 | 254 | sectionConfigs[section.name] = sectionConfigCheck(section) 255 | sectionNamesCheckedForInitialCollapse.insert(section.name) 256 | } 257 | 258 | return CollapsibleResultsSection( 259 | section: section, 260 | collapsed: collapsedSectionNames.contains(section.name), 261 | config: sectionConfigs[section.name] 262 | ) 263 | } 264 | } 265 | 266 | // MARK: - Fetch Methods 267 | public extension CollapsibleSectionsFetchedResultsController { 268 | @MainActor 269 | func performFetch( 270 | completion: @escaping @MainActor @Sendable () -> Void = {} 271 | ) { 272 | fetchController.performFetch(completion: completion) 273 | } 274 | 275 | @MainActor 276 | func resort( 277 | using newSortDescriptors: [NSSortDescriptor], 278 | completion: @escaping @MainActor @Sendable () -> Void = {} 279 | ) { 280 | fetchController.resort(using: newSortDescriptors, completion: completion) 281 | } 282 | } 283 | 284 | // MARK: - FetchedResultsControllerDelegate 285 | extension CollapsibleSectionsFetchedResultsController: FetchedResultsControllerDelegate { 286 | public func controllerWillChangeContent(_ controller: FetchedResultsController) { 287 | collapsedSectionsModifiedDuringContentChange.removeAll() 288 | changedSectionsDuringContentChange.removeAll() 289 | deletedSectionsDuringContentChange.removeAll() 290 | previousSectionsDuringContentChange = self.sections 291 | 292 | delegate?.controllerWillChangeContent(self) 293 | } 294 | 295 | public func controllerDidChangeContent(_ controller: FetchedResultsController) { 296 | let sectionsToNotify = collapsedSectionsModifiedDuringContentChange.filter { section in 297 | !changedSectionsDuringContentChange.contains(section) && !deletedSectionsDuringContentChange.contains(section) 298 | } 299 | for sectionName in sectionsToNotify { 300 | guard let section = previousSectionsDuringContentChange.first(where: { $0.name == sectionName }), 301 | let index = previousSectionsDuringContentChange.firstIndex(of: section) 302 | else { 303 | continue 304 | } 305 | 306 | let change: FetchedResultsChange = .update(location: index) 307 | delegate?.controller(self, didChange: section, for: change) 308 | } 309 | 310 | collapsedSectionsModifiedDuringContentChange.removeAll() 311 | changedSectionsDuringContentChange.removeAll() 312 | deletedSectionsDuringContentChange.removeAll() 313 | previousSectionsDuringContentChange.removeAll() 314 | 315 | delegate?.controllerDidChangeContent(self) 316 | } 317 | 318 | public func controller( 319 | _ controller: FetchedResultsController, 320 | didChange object: FetchedObject, 321 | for change: FetchedResultsChange 322 | ) { 323 | switch change { 324 | case let .insert(indexPath): 325 | updateSections(atIndices: indexPath.section) 326 | 327 | let section = sections[indexPath.section] 328 | if section.isCollapsed { 329 | collapsedSectionNames.insert(section.name) 330 | collapsedSectionsModifiedDuringContentChange.insert(section.name) 331 | } else { 332 | delegate?.controller(self, didChange: object, for: change) 333 | } 334 | 335 | case let .update(indexPath), let .delete(indexPath): 336 | updateSections(atIndices: indexPath.section) 337 | 338 | let section = sections[indexPath.section] 339 | if section.isCollapsed { 340 | collapsedSectionsModifiedDuringContentChange.insert(section.name) 341 | } else { 342 | delegate?.controller(self, didChange: object, for: change) 343 | } 344 | 345 | case let .move(fromIndexPath, toIndexPath): 346 | updateSections(atIndices: fromIndexPath.section, toIndexPath.section) 347 | 348 | let fromSection = sections[fromIndexPath.section] 349 | let toSection = sections[toIndexPath.section] 350 | 351 | if fromSection.isCollapsed, toSection.isCollapsed { 352 | collapsedSectionsModifiedDuringContentChange.insert(fromSection.name) 353 | collapsedSectionsModifiedDuringContentChange.insert(toSection.name) 354 | } else if fromSection.isCollapsed { 355 | collapsedSectionsModifiedDuringContentChange.insert(fromSection.name) 356 | delegate?.controller(self, didChange: object, for: .insert(location: toIndexPath)) 357 | } else if toSection.isCollapsed { 358 | collapsedSectionsModifiedDuringContentChange.insert(toSection.name) 359 | delegate?.controller(self, didChange: object, for: .delete(location: fromIndexPath)) 360 | } else { 361 | delegate?.controller(self, didChange: object, for: change) 362 | } 363 | } 364 | } 365 | 366 | public func controller( 367 | _ controller: FetchedResultsController, 368 | didChange section: FetchedResultsSection, 369 | for change: FetchedResultsChange 370 | ) { 371 | let sectionToNotify: CollapsibleResultsSection 372 | var isDelete = false 373 | var isInsert = false 374 | 375 | switch change { 376 | case let .insert(sectionIndex): 377 | isInsert = true 378 | sectionToNotify = createSection(from: controller.sections[sectionIndex]) 379 | sections.insert(sectionToNotify, at: sectionIndex) 380 | 381 | case let .delete(sectionIndex): 382 | isDelete = true 383 | sectionToNotify = sections[sectionIndex] 384 | sections.remove(at: sectionIndex) 385 | 386 | case let .move(oldSectionIndex, newSectionIndex): 387 | sectionToNotify = createSection(from: controller.sections[newSectionIndex]) 388 | 389 | sections.remove(at: oldSectionIndex) 390 | sections.insert(sectionToNotify, at: newSectionIndex) 391 | 392 | case let .update(sectionIndex): 393 | sectionToNotify = createSection(from: controller.sections[sectionIndex]) 394 | sections[sectionIndex] = sectionToNotify 395 | } 396 | 397 | if isDelete { 398 | deletedSectionsDuringContentChange.insert(sectionToNotify.name) 399 | changedSectionsDuringContentChange.remove(sectionToNotify.name) 400 | } else { 401 | changedSectionsDuringContentChange.insert(sectionToNotify.name) 402 | } 403 | 404 | if isInsert { 405 | deletedSectionsDuringContentChange.remove(sectionToNotify.name) 406 | } 407 | 408 | delegate?.controller(self, didChange: sectionToNotify, for: change) 409 | } 410 | } 411 | 412 | // MARK: - DelegateThunk 413 | 414 | private class DelegateThunk { 415 | typealias Parent = CollapsibleSectionsFetchedResultsControllerDelegate 416 | typealias Controller = CollapsibleSectionsFetchedResultsController 417 | typealias Section = CollapsibleResultsSection 418 | 419 | private weak var parent: (any Parent)? 420 | 421 | #if compiler(<6) 422 | private let willChange: @MainActor (_ controller: Controller) -> Void 423 | private let didChange: @MainActor (_ controller: Controller) -> Void 424 | 425 | private let changeObject: @MainActor (_ controller: Controller, _ object: FetchedObject, _ change: FetchedResultsChange) -> Void 426 | private let changeSection: @MainActor (_ controller: Controller, _ section: Section, _ change: FetchedResultsChange) -> Void 427 | #endif 428 | 429 | init(_ parent: some Parent) { 430 | self.parent = parent 431 | 432 | #if compiler(<6) 433 | willChange = { [weak parent] controller in 434 | parent?.controllerWillChangeContent(controller) 435 | } 436 | didChange = { [weak parent] controller in 437 | parent?.controllerDidChangeContent(controller) 438 | } 439 | 440 | changeObject = { [weak parent] controller, object, change in 441 | parent?.controller(controller, didChange: object, for: change) 442 | } 443 | changeSection = { [weak parent] controller, section, change in 444 | parent?.controller(controller, didChange: section, for: change) 445 | } 446 | #endif 447 | } 448 | } 449 | 450 | extension DelegateThunk: CollapsibleSectionsFetchedResultsControllerDelegate { 451 | func controllerWillChangeContent(_ controller: Controller) { 452 | #if compiler(>=6) 453 | self.parent?.controllerWillChangeContent(controller) 454 | #else 455 | self.willChange(controller) 456 | #endif 457 | } 458 | 459 | func controllerDidChangeContent(_ controller: Controller) { 460 | #if compiler(>=6) 461 | self.parent?.controllerDidChangeContent(controller) 462 | #else 463 | self.didChange(controller) 464 | #endif 465 | } 466 | 467 | func controller( 468 | _ controller: Controller, 469 | didChange object: FetchedObject, 470 | for change: FetchedResultsChange 471 | ) { 472 | #if compiler(>=6) 473 | self.parent?.controller(controller, didChange: object, for: change) 474 | #else 475 | self.changeObject(controller, object, change) 476 | #endif 477 | } 478 | 479 | func controller( 480 | _ controller: Controller, 481 | didChange section: Section, 482 | for change: FetchedResultsChange 483 | ) { 484 | #if compiler(>=6) 485 | self.parent?.controller(controller, didChange: section, for: change) 486 | #else 487 | self.changeSection(controller, section, change) 488 | #endif 489 | } 490 | } 491 | --------------------------------------------------------------------------------