diff --git a/PredicateKit/SwiftUI/SwiftUISupport.swift b/PredicateKit/SwiftUI/SwiftUISupport.swift index e18766f..d888cf6 100644 --- a/PredicateKit/SwiftUI/SwiftUISupport.swift +++ b/PredicateKit/SwiftUI/SwiftUISupport.swift @@ -164,3 +164,155 @@ extension FetchRequest { self.init(predicate: true) } } + +@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) +extension FetchedResults where Result: NSManagedObject { + /// Changes the `Predicate` used in filtering the `@SwiftUI.FetchRequest` property. + /// + /// - Parameter newPredicate: The predicate used to define a filter for the fetched results. + /// + /// ## Example + /// + /// struct ContentView: View { + /// @SwiftUI.FetchRequest(predicate: \Note.text == "Hello, World!") + /// var notes: FetchedResults + /// + /// var body: some View { + /// List(notes, id: \.self) { + /// Text($0.text) + /// } + /// Button("Show All") { + /// notes.updatePredicate(true) + /// } + /// } + /// + public func updatePredicate(_ newPredicate: Predicate) { + let entityName = Result.entity().name ?? String(describing: Result.self) + let fetchRequestBuilder = NSFetchRequestBuilder(entityName: entityName) + let nsFetchRequest: NSFetchRequest = fetchRequestBuilder.makeRequest( + from: FetchRequest(predicate: newPredicate) + ) + self.nsPredicate = nsFetchRequest.predicate + } +} + +@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) +extension SectionedFetchRequest where Result: NSManagedObject { + /// Creates an instance from the provided fetch request and animation. + /// + /// - Parameter fetchRequest: The request used to produce the fetched results. + /// - Parameter sectionIdentifier: A key path that SwiftUI applies to the Result type to get an object’s section identifier. + /// - Parameter animation: The animation used for any changes to the fetched results. + /// + /// ## Example + /// + /// struct ContentView: View { + /// @SwiftUI.SectionedFetchRequest( + /// fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + /// sectionIdentifier: \.billingInfo.accountType + /// ) + /// var users: SectionedFetchResults + /// + /// var body: some View { + /// List(users, id: \.id) { section in + /// Section(section.id) { + /// ForEach(section, id: \.objectID) { user in + /// Text(user.name) + /// } + /// } + /// } + /// } + /// + public init( + fetchRequest: FetchRequest, + sectionIdentifier: KeyPath, + animation: Animation? = nil + ) { + let entityName = Result.entity().name ?? String(describing: Result.self) + let fetchRequestBuilder = NSFetchRequestBuilder(entityName: entityName) + self.init( + fetchRequest: fetchRequestBuilder.makeRequest(from: fetchRequest), + sectionIdentifier: sectionIdentifier, + animation: animation + ) + } + + /// Creates an instance from the provided fetch request and transaction. + /// + /// - Parameter fetchRequest: The request used to produce the fetched results. + /// - Parameter sectionIdentifier: A key path that SwiftUI applies to the Result type to get an object’s section identifier. + /// - Parameter transaction: The transaction used for any changes to the fetched results. + /// + /// ## Example + /// + /// struct ContentView: View { + /// @SwiftUI.SectionedFetchRequest( + /// fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + /// sectionIdentifier: \.billingInfo.accountType + /// transaction: Transaction(animation: .easeIn) + /// ) + /// var users: SectionedFetchResults + /// + /// var body: some View { + /// List(users, id: \.id) { section in + /// Section(section.id) { + /// ForEach(section, id: \.objectID) { user in + /// Text(user.name) + /// } + /// } + /// } + /// } + /// + public init( + fetchRequest: FetchRequest, + sectionIdentifier: KeyPath, + transaction: Transaction + ) { + let entityName = Result.entity().name ?? String(describing: Result.self) + let fetchRequestBuilder = NSFetchRequestBuilder(entityName: entityName) + self.init( + fetchRequest: fetchRequestBuilder.makeRequest(from: fetchRequest), + sectionIdentifier: sectionIdentifier, + transaction: transaction + ) + } +} + +@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) +extension SectionedFetchResults where Result: NSManagedObject { + /// Changes the `Predicate` used in filtering the `@SwiftUI.FetchRequest` property. + /// + /// - Parameter newPredicate: The predicate used to define a filter for the fetched results. + /// + /// ## Example + /// + /// struct ContentView: View { + /// @SwiftUI.SectionedFetchRequest( + /// fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + /// sectionIdentifier: \.billingInfo.accountType + /// transaction: Transaction(animation: .easeIn) + /// ) + /// var users: SectionedFetchResults + /// + /// var body: some View { + /// List(users, id: \.id) { section in + /// Section(section.id) { + /// ForEach(section, id: \.objectID) { user in + /// Text(user.name) + /// } + /// } + /// } + /// Button("Show All") { + /// users.updatePredicate(true) + /// } + /// } + /// + public func updatePredicate(_ newPredicate: Predicate) { + let entityName = Result.entity().name ?? String(describing: Result.self) + let fetchRequestBuilder = NSFetchRequestBuilder(entityName: entityName) + let nsFetchRequest: NSFetchRequest = fetchRequestBuilder.makeRequest( + from: FetchRequest(predicate: newPredicate) + ) + self.nsPredicate = nsFetchRequest.predicate + } +} diff --git a/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift b/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift index 6e676ac..640f664 100644 --- a/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift +++ b/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift @@ -21,6 +21,9 @@ import CoreData import Foundation import SwiftUI +#if canImport(UIKit) +import UIKit +#endif import XCTest @testable import PredicateKit @@ -202,6 +205,284 @@ class SwiftUISupportTests: XCTestCase { XCTAssertEqual(request.projectedValue.wrappedValue.nsPredicate, NSPredicate(value: true)) } + + #if canImport(UIKit) && !canImport(WatchKit) + func testFetchRequestPropertyWrapperWithChangingPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.FetchRequest(fetchRequest: FetchRequest()) + var notes: FetchedResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(notes, id: \.self) { + Text($0.text) + }.onAppear { + notes.updatePredicate(\Note.text == "Hello, World!") + + let comparison = notes.nsPredicate as? NSComparisonPredicate + XCTAssertEqual(comparison?.leftExpression, NSExpression(forKeyPath: "text")) + XCTAssertEqual(comparison?.rightExpression, NSExpression(forConstantValue: "Hello, World!")) + XCTAssertEqual(comparison?.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison?.comparisonPredicateModifier, .direct) + + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + + // Trick the system into installing the view in a "view hierarchy" so we can + // access any underlying @StateObject without crashing. + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + } + + func testSectionedFetchRequestPropertyWrapperWithNoPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest() + .sorted(by: \.billingInfo.accountType, .ascending) + .sorted(by: \.name, .ascending), + sectionIdentifier: \.billingInfo.accountType + ) + var users: SectionedFetchResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + }.onAppear { + XCTAssertEqual(users.sectionIdentifier, \.billingInfo.accountType) + XCTAssertEqual(users.nsPredicate, NSPredicate(value: true)) + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + } + + func testSectionedFetchRequestPropertyWrapperWithAnimationAndNoPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest() + .sorted(by: \.billingInfo.accountType, .ascending) + .sorted(by: \.name, .ascending), + sectionIdentifier: \.billingInfo.accountType, + animation: .easeIn + ) + var users: SectionedFetchResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + }.onAppear { + XCTAssertEqual(users.nsPredicate, NSPredicate(value: true)) + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + + let transaction = try XCTUnwrap( + Mirror(reflecting: view).descendant("content", "_users", "transaction") as? Transaction + ) + + XCTAssertEqual(transaction.animation, .easeIn) + } + + func testSectionedFetchRequestPropertyWrapperWithTransactionAndNoPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest() + .sorted(by: \.billingInfo.accountType, .ascending) + .sorted(by: \.name, .ascending), + sectionIdentifier: \.billingInfo.accountType, + transaction: .nonContinuousEaseInOut + ) + var users: SectionedFetchResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + }.onAppear { + XCTAssertEqual(users.nsPredicate, NSPredicate(value: true)) + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + + let transaction = try XCTUnwrap( + Mirror(reflecting: view).descendant("content", "_users", "transaction") as? Transaction + ) + + XCTAssertEqual(transaction.animation, .easeInOut) + XCTAssertFalse(transaction.isContinuous) + } + + func testSectionedFetchRequestPropertyWrapperWithBasicPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + sectionIdentifier: \.billingInfo.accountType + ) + var users: SectionedFetchResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + }.onAppear { + let comparison = users.nsPredicate as? NSComparisonPredicate + XCTAssertEqual(comparison?.leftExpression, NSExpression(forKeyPath: "name")) + XCTAssertEqual(comparison?.rightExpression, NSExpression(forConstantValue: "John Doe")) + XCTAssertEqual(comparison?.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison?.comparisonPredicateModifier, .direct) + + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + } + + func testSectionedFetchRequestPropertyWrapperWithChangingPredicate() throws { + let expectation = self.expectation(description: "assertions complete successfully") + + struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + sectionIdentifier: \.billingInfo.accountType + ) + var users: SectionedFetchResults + + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + }.onAppear { + users.updatePredicate(\User.billingInfo.accountType == "test") + + let comparison = users.nsPredicate as? NSComparisonPredicate + XCTAssertEqual(comparison?.leftExpression, NSExpression(forKeyPath: "billingInfo.accountType")) + XCTAssertEqual(comparison?.rightExpression, NSExpression(forConstantValue: "test")) + XCTAssertEqual(comparison?.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison?.comparisonPredicateModifier, .direct) + + completion() + } + } + } + + let view = ContentView { expectation.fulfill() } + .environment(\.managedObjectContext, testContainer().viewContext) + let window = UIWindow(frame: .zero) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + + wait(for: [expectation], timeout: 5) + } + #endif + + private func testContainer() -> NSPersistentContainer { + let model = NSManagedObjectModel.mergedModel(from: [Bundle(for: SwiftUISupportTests.self)])! + return makePersistentContainer(with: model) + } } // MARK: - diff --git a/README.md b/README.md index 8ba11b5..eb70d75 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ comparisons and logical operators, literal values, and functions. - [Fetching objects](#fetching-objects) - [Configuring the fetch](#configuring-the-fetch) - [Fetching objects with the @FetchRequest property wrapper](#fetching-objects-with-the-fetchrequest-property-wrapper) + - [Fetching objects with the @SectionedFetchRequest property wrapper](#fetching-objects-with-the-sectionedfetchrequest-property-wrapper) - [Fetching objects with an NSFetchedResultsController](#fetching-objects-with-an-nsfetchedresultscontroller) - [Counting objects](#counting-objects) - [Documentation](#documentation) @@ -229,6 +230,94 @@ struct ContentView: View { } ``` +You can update the predicate associated with your `FetchedResults` using `updatePredicate`. + +###### Example + +```swift +import PredicateKit +import SwiftUI + +struct ContentView: View { + + @SwiftUI.FetchRequest(predicate: \Note.text == "Hello, World!") + var notes: FetchedResults + + var body: some View { + List(notes, id: \.self) { + Text($0.text) + } + Button("Show recents") { + let recentDate: Date = // ... + notes.updatePredicate(\Note.createdAt >= recentDate) + } + } +} +``` + +This will cause the associated `FetchRequest` to execute a fetch with the new predicate when the `Show recents` button is tapped. + +## Fetching objects with the @SectionedFetchRequest property wrapper + +PredicateKit also extends the SwiftUI [`@SectionedFetchRequest`](https://developer.apple.com/documentation/swiftui/sectionedfetchrequest) property wrapper to support type-safe predicates. + +###### Example + +```swift +import PredicateKit +import SwiftUI + +struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + sectionIdentifier: \.billingInfo.accountType + ) + var users: SectionedFetchResults + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + } + } +``` + +You can update the predicate associated with your `SectionedFetchedResults` using `updatePredicate`. + +###### Example + +```swift +import PredicateKit +import SwiftUI + +struct ContentView: View { + @SwiftUI.SectionedFetchRequest( + fetchRequest: FetchRequest(predicate: \User.name == "John Doe"), + sectionIdentifier: \.billingInfo.accountType + ) + var users: SectionedFetchResults + + var body: some View { + List(users, id: \.id) { section in + Section(section.id) { + ForEach(section, id: \.objectID) { user in + Text(user.name) + } + } + } + Button("Search") { + let query: String = // ... + users.updatePredicate((\User.name).contains(query)) + } + } +} +``` + +This will cause the associated `FetchRequest` to execute a fetch with the new predicate when the `Search` button is tapped. + ## Fetching objects with an NSFetchedResultsController In UIKit, you can use `fetchedResultsController()` to create an `NSFetchedResultsController` from a configured fetch request. `fetchedResultsController` has two optional parameters: