├── 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 | [](https://swiftpackageindex.com/square/FetchRequests)
6 | [](https://github.com/square/FetchRequests/releases)
7 | [](https://github.com/square/FetchRequests/actions/workflows/build.yml)
8 | [](https://codecov.io/gh/square/FetchRequests)
9 | [](https://swiftpackageindex.com/square/FetchRequests)
10 | [](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 |
--------------------------------------------------------------------------------