Chapter 8: Predicates and Sort Descriptors

The Power of NSPredicate

Anatomy of a Predicate

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
}
  • "amount" is the key path. It directly corresponds to the property name of our Core Data entity.
  • > is the comparison operator.
  • %f is the substitution variable for a floating-point number.
  • 50.0 is the argument that gets safely substituted into the %f placeholder.

Common Predicate Formats and Modifiers

1. String Comparisons

  • Exact Match: NSPredicate(format: "name == %@", "Coffee")
  • Case-Insensitive Match: NSPredicate(format: "name [c] == %@", "coffee"). The [c] modifier ignores case. "Coffee" matches "coffee".
  • Diacritic-Insensitive Match: NSPredicate(format: "name [d] == %@", "cafe"). The [d] modifier ignores diacritics (accents). "café" matches "cafe".
  • Combined Modifiers: NSPredicate(format: "name CONTAINS[cd] %@", "bucks"). This is the holy grail for user-facing search. It ignores both case and accents, ensuring maximum leniency for user typos.
  • Begins/Ends With: NSPredicate(format: "name BEGINSWITH[c] %@", "Star")

[!TIP] Performance Check: Using CONTAINS[cd] is very user-friendly but it prevents SQLite from using standard indexes efficiently because it has to inspect the interior of every string. If you are searching a massive database, BEGINSWITH[cd] is significantly faster because it can leverage a standard string index.

2. Number and Date Comparisons

  • Amount greater than: NSPredicate(format: "amount >= %@", NSNumber(value: 100))
  • Date before today: NSPredicate(format: "date < %@", Date() as NSDate)

3. Collection Membership (IN)

  • Match multiple categories:
    import Foundation
    import CoreData
    
    @objc(Expense)
    public class Expense: NSManagedObject, Identifiable {
        @NSManaged public var amount: Double
        @NSManaged public var name: String?
        @NSManaged public var date: Date?
        @NSManaged public var categoryName: String?
        @NSManaged public var isBusiness: Bool
        @NSManaged public var notes: String?
        
        @nonobjc public class func fetchRequest() -> NSFetchRequest {
            return NSFetchRequest(entityName: "Expense")
        }
    }
    
    class PredicateExamples {
        func simplePredicate() {
            // A simple predicate to find expenses over $50
            let predicate = NSPredicate(format: "amount > %f", 50.0)
        }
    
        func collectionMembership() {
            let categories = ["Food", "Transport", "Utilities"]
            let predicate = NSPredicate(format: "categoryName IN %@", categories)
        }
    }
    

4. Regular Expressions (MATCHES)

  • Find numeric-only names: NSPredicate(format: "name MATCHES %@", "^[0-9]+$")

Dynamic Predicates: Variable Substitution vs String Interpolation

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
}

Dynamic Key Paths with %K

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }

    func dynamicKeyPaths() {
        // Let the user choose whether to search by "name" or "notes"
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"

        // ❌ BAD: %@ puts quotes around the property name, treating it as a string literal, not a key path.
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)

        // ✅ GOOD: %K tells the parser to treat the argument as a key path.
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
}
flowchart TD A["User Inputs Search Data"] --> B("ViewModel creates parameters") B --> C{"Create NSPredicate"} C -->|"Format String"| D["format: '%K CONTAINS[cd] %@'"] C -->|"Safe Substitution"| E["args: 'name', 'Coffee'"] D --> F["NSFetchRequest"] E --> F F --> G["Core Data Engine"] G --> H[("SQLite Database")] H -->|"Optimized Query"| I["Filtered Results Returned"]

Mastering NSCompoundPredicate

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }

    func dynamicKeyPaths() {
        // Let the user choose whether to search by "name" or "notes"
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"

        // ❌ BAD: %@ puts quotes around the property name, treating it as a string literal, not a key path.
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)

        // ✅ GOOD: %K tells the parser to treat the argument as a key path.
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }

    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")

        // Combine them dynamically using AND
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
}
graph TD subgraph UIState___UI_State__ ["UIState ["UI State"]"] D["Date Filter Active?"] A["Amount Filter Active?"] S["Search Text Active?"] end D -->|"Yes"| DP["Date Predicate"] A -->|"Yes"| AP["Amount Predicate"] S -->|"Yes"| SP["Search Predicate"] DP --> CP{"NSCompoundPredicate AND"} AP --> CP SP --> CP CP --> FR["Final NSFetchRequest"]

Bringing Order with NSSortDescriptor

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }

    func dynamicKeyPaths() {
        // Let the user choose whether to search by "name" or "notes"
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"

        // ❌ BAD: %@ puts quotes around the property name, treating it as a string literal, not a key path.
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)

        // ✅ GOOD: %K tells the parser to treat the argument as a key path.
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }

    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")

        // Combine them dynamically using AND
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }

    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
}

Multiple Sort Descriptors & Tie-Breakers

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }

    func dynamicKeyPaths() {
        // Let the user choose whether to search by "name" or "notes"
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"

        // ❌ BAD: %@ puts quotes around the property name, treating it as a string literal, not a key path.
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)

        // ✅ GOOD: %K tells the parser to treat the argument as a key path.
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }

    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")

        // Combine them dynamically using AND
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }

    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }

    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        // Sort by Amount (highest first). If two items cost the same, put the newer one first.
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
}

Localized Sorting

import Foundation
import CoreData

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        // A simple predicate to find expenses over $50
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }

    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }

    func dynamicPredicates() {
        let searchString = "Trader Joe's" 

        // ❌ BAD: Prone to crashes and injection. The single quote in "Joe's" breaks the syntax.
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!

        // ✅ GOOD: Safe argument substitution. Core Data escapes the string automatically.
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }

    func dynamicKeyPaths() {
        // Let the user choose whether to search by "name" or "notes"
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"

        // ❌ BAD: %@ puts quotes around the property name, treating it as a string literal, not a key path.
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)

        // ✅ GOOD: %K tells the parser to treat the argument as a key path.
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }

    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")

        // Combine them dynamically using AND
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }

    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }

    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        // Sort by Amount (highest first). If two items cost the same, put the newer one first.
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }

    func localizedSorting() {
        // Uses NSString's localizedStandardCompare: for natural, case-insensitive sorting.
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

[!NOTE] When executing a fetch request against a SQLite store, Core Data pushes the sort down to the database level. However, SQLite doesn't natively support all custom Swift/Objective-C selectors. It supports basic comparisons, caseInsensitiveCompare:, and localizedStandardCompare:. If you try to use a highly custom selector, Core Data will have to pull all matching records into memory and sort them in Foundation, which negates the performance benefits of SQLite.

Architecting Filters and Sorts in MVVM

  1. Search for expenses by name.
  2. Filter by a specific date range.
  3. Sort by Date (Newest/Oldest) or Amount (Highest/Lowest).

1. The Repository Layer

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    
    // We inject the main context, but we might use a background context for heavy lifting.
    init(context: NSManagedObjectContext) {
        self.context = context
    }
    
    /// Fetches expenses asynchronously on a background context to avoid blocking the main thread.
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        
        // Use withCheckedThrowingContinuation to bridge completion handlers to async/await
        return try await withCheckedThrowingContinuation { continuation in
            
            // Assuming `PersistentContainer.shared` is accessible, or passed in. 
            // For this example, we'll use the context's persistentStoreCoordinator 
            // to create a background context safely.
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                
                // PERFORMANCE TIP: If we only need to display data and not edit it immediately,
                // we can return the objects as faults or dictionaries.
                // request.fetchBatchSize = 20 // Optimizes memory for large lists
                
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    
                    // Core Data objects are NOT thread-safe. We cannot pass `backgroundExpenses` 
                    // directly back to the main thread. We must pass their NSManagedObjectIDs.
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    
                    // Switch back to the main context to realize the objects for the UI
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { 
                            self.context.object(with: $0) as? Expense 
                        }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

2. Defining Sort Options

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    
    // We inject the main context, but we might use a background context for heavy lifting.
    init(context: NSManagedObjectContext) {
        self.context = context
    }
    
    /// Fetches expenses asynchronously on a background context to avoid blocking the main thread.
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        
        // Use withCheckedThrowingContinuation to bridge completion handlers to async/await
        return try await withCheckedThrowingContinuation { continuation in
            
            // Assuming `PersistentContainer.shared` is accessible, or passed in. 
            // For this example, we'll use the context's persistentStoreCoordinator 
            // to create a background context safely.
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                
                // PERFORMANCE TIP: If we only need to display data and not edit it immediately,
                // we can return the objects as faults or dictionaries.
                // request.fetchBatchSize = 20 // Optimizes memory for large lists
                
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    
                    // Core Data objects are NOT thread-safe. We cannot pass `backgroundExpenses` 
                    // directly back to the main thread. We must pass their NSManagedObjectIDs.
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    
                    // Switch back to the main context to realize the objects for the UI
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { 
                            self.context.object(with: $0) as? Expense 
                        }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest:
            return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest:
            return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest:
            return [NSSortDescriptor(key: "amount", ascending: false),
                    NSSortDescriptor(key: "date", ascending: false)] // Tie-breaker
        case .amountLowest:
            return [NSSortDescriptor(key: "amount", ascending: true),
                    NSSortDescriptor(key: "date", ascending: false)] // Tie-breaker
        }
    }
}

3. The ViewModel

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    
    // UI Binding Properties
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        
        // Initial fetch
        Task { await fetchData() }
    }
    
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            // Debounce the search text so we don't query Core Data on every keystroke
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        
        // 1. Build the Compound Predicate dynamically
        var subpredicates: [NSPredicate] = []
        
        // Add Search Predicate (Ignore whitespace)
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        
        // Add Date Range Predicate
        if let start = startDate {
            subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate))
        }
        if let end = endDate {
            subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate))
        }
        
        // Combine all predicates safely
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        
        // 2. Get Sort Descriptors
        let descriptors = selectedSortOption.sortDescriptors
        
        // 3. Fetch from Repository Asynchronously
        do {
            let fetchedData = try await repository.fetchExpenses(
                predicate: finalPredicate,
                sortDescriptors: descriptors
            )
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
            print("Fetch error: \\(error)")
        }
        
        isLoading = false
    }
    
    func clearDateFilter() {
        startDate = nil
        endDate = nil
    }
}

Why Debounce and Task Cancellation?

4. The SwiftUI View

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        Task { await fetchData() }
    }
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        var subpredicates: [NSPredicate] = []
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        if let start = startDate { subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate)) }
        if let end = endDate { subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate)) }
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        let descriptors = selectedSortOption.sortDescriptors
        do {
            let fetchedData = try await repository.fetchExpenses(predicate: finalPredicate, sortDescriptors: descriptors)
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
        }
        isLoading = false
    }
    func clearDateFilter() { startDate = nil; endDate = nil }
}

struct ExpenseRow: View {
    let expense: Expense
    var body: some View {
        Text(expense.name ?? "Unknown")
    }
}

struct ExpenseListView: View {
    @State var viewModel: ExpenseListViewModel
    @State private var showingFilters = false
    
    var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } else if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage).foregroundColor(.red)
                } else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRow(expense: expense)
                        }
                    }
                }
            }
            .navigationTitle("Expenses")
            .searchable(text: $viewModel.searchText, prompt: "Search expenses")
            .onChange(of: viewModel.searchText) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.selectedSortOption) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.startDate) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.endDate) { _, _ in viewModel.performSearch() }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Menu {
                        // Sort Options
                        Section(header: Text("Sort By")) {
                            Picker("Sort By", selection: $viewModel.selectedSortOption) {
                                ForEach(ExpenseSortOption.allCases, id: \.self) { option in
                                    Text(option.rawValue).tag(option)
                                }
                            }
                        }
                        
                        // Filter Toggle
                        Section {
                            Button { showingFilters = true } label: { Label("Date Filters", systemImage: "line.3.horizontal.decrease.circle") }
                            if viewModel.startDate != nil || viewModel.endDate != nil {
                                Button(role: .destructive) { viewModel.clearDateFilter() } label: { Label("Clear Filters", systemImage: "xmark.circle") }
                            }
                        }
                    } label: { Image(systemName: "ellipsis.circle") }
                }
            }
            .sheet(isPresented: $showingFilters) {
                DateFilterSheet(
                    startDate: Binding(
                        get: { viewModel.startDate ?? Date().addingTimeInterval(-86400 * 30) },
                        set: { viewModel.startDate = $0 }
                    ),
                    endDate: Binding(
                        get: { viewModel.endDate ?? Date() },
                        set: { viewModel.endDate = $0 }
                    )
                )
            }
        }
    }
}

// A simple sheet to select a date range
struct DateFilterSheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var startDate: Date
    @Binding var endDate: Date
    var body: some View {
        NavigationView {
            Form {
                DatePicker("Start Date", selection: $startDate, displayedComponents: .date)
                DatePicker("End Date", selection: $endDate, displayedComponents: .date)
            }
            .navigationTitle("Filter by Date")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } }
        }
    }
}

NSFetchedResultsController and Dynamic Filtering

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        Task { await fetchData() }
    }
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        var subpredicates: [NSPredicate] = []
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        if let start = startDate { subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate)) }
        if let end = endDate { subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate)) }
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        let descriptors = selectedSortOption.sortDescriptors
        do {
            let fetchedData = try await repository.fetchExpenses(predicate: finalPredicate, sortDescriptors: descriptors)
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
        }
        isLoading = false
    }
    func clearDateFilter() { startDate = nil; endDate = nil }
}

struct ExpenseRow: View {
    let expense: Expense
    var body: some View {
        Text(expense.name ?? "Unknown")
    }
}

struct ExpenseListView: View {
    @State var viewModel: ExpenseListViewModel
    @State private var showingFilters = false
    
    var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } else if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage).foregroundColor(.red)
                } else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRow(expense: expense)
                        }
                    }
                }
            }
            .navigationTitle("Expenses")
            .searchable(text: $viewModel.searchText, prompt: "Search expenses")
            .onChange(of: viewModel.searchText) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.selectedSortOption) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.startDate) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.endDate) { _, _ in viewModel.performSearch() }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Menu {
                        Section(header: Text("Sort By")) {
                            Picker("Sort By", selection: $viewModel.selectedSortOption) {
                                ForEach(ExpenseSortOption.allCases, id: \.self) { option in
                                    Text(option.rawValue).tag(option)
                                }
                            }
                        }
                        Section {
                            Button { showingFilters = true } label: { Label("Date Filters", systemImage: "line.3.horizontal.decrease.circle") }
                            if viewModel.startDate != nil || viewModel.endDate != nil {
                                Button(role: .destructive) { viewModel.clearDateFilter() } label: { Label("Clear Filters", systemImage: "xmark.circle") }
                            }
                        }
                    } label: { Image(systemName: "ellipsis.circle") }
                }
            }
            .sheet(isPresented: $showingFilters) {
                DateFilterSheet(
                    startDate: Binding(
                        get: { viewModel.startDate ?? Date().addingTimeInterval(-86400 * 30) },
                        set: { viewModel.startDate = $0 }
                    ),
                    endDate: Binding(
                        get: { viewModel.endDate ?? Date() },
                        set: { viewModel.endDate = $0 }
                    )
                )
            }
        }
    }
}

struct DateFilterSheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var startDate: Date
    @Binding var endDate: Date
    var body: some View {
        NavigationView {
            Form {
                DatePicker("Start Date", selection: $startDate, displayedComponents: .date)
                DatePicker("End Date", selection: $endDate, displayedComponents: .date)
            }
            .navigationTitle("Filter by Date")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } }
        }
    }
}

@Observable class ExpenseFRCViewModel: NSObject {
    private var fetchedResultsController: NSFetchedResultsController!
    
    // ... setup and bindings ...
    
    func applyFilters(predicate: NSPredicate?, sortDescriptors: [NSSortDescriptor]) {
        // 1. Delete the cache! Critical step to prevent crashes.
        NSFetchedResultsController.deleteCache(withName: "ExpenseCache")
        
        // 2. Update the FRC's request
        fetchedResultsController.fetchRequest.predicate = predicate
        fetchedResultsController.fetchRequest.sortDescriptors = sortDescriptors
        
        // 3. Re-fetch
        do {
            try fetchedResultsController.performFetch()
            // In a real app, you would now update your @Published properties 
            // by mapping fetchedResultsController.fetchedObjects
        } catch {
            print("Failed to re-fetch FRC: \\(error)")
        }
    }
}

[!CAUTION] Cache Invalidation: When modifying the fetchRequest of an existing NSFetchedResultsController, you are pulling an entirely new subset of data. If you initialized your FRC with a cacheName, the existing cache represents the old data. You must call NSFetchedResultsController.deleteCache(withName:) before changing the predicate or sort descriptors, otherwise your app will likely crash with an inconsistency exception.

Advanced Tips & Extreme Performance

1. SQLite Indexing

  1. Open your .xcdatamodeld file.
  2. Select the Expense entity.
  3. Select the date attribute.
  4. In the Data Model Inspector on the right, check the box for Indexed.

2. Predicate Ordering matters!

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
    func predicateOrdering() {
        // ❌ SLOW: Evaluates the expensive string comparison on ALL 100,000 records first.
        let slowPredicate = NSPredicate(format: "name CONTAINS[cd] %@ AND amount > 100", "coffee")
        
        // ✅ FAST: Filters out all expenses under $100 first (cheap). 
        // THEN evaluates the string comparison only on the remaining 500 records.
        let fastPredicate = NSPredicate(format: "amount > 100 AND name CONTAINS[cd] %@", "coffee")
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        Task { await fetchData() }
    }
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        var subpredicates: [NSPredicate] = []
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        if let start = startDate { subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate)) }
        if let end = endDate { subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate)) }
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        let descriptors = selectedSortOption.sortDescriptors
        do {
            let fetchedData = try await repository.fetchExpenses(predicate: finalPredicate, sortDescriptors: descriptors)
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
        }
        isLoading = false
    }
    func clearDateFilter() { startDate = nil; endDate = nil }
}

struct ExpenseRow: View {
    let expense: Expense
    var body: some View {
        Text(expense.name ?? "Unknown")
    }
}

struct ExpenseListView: View {
    @State var viewModel: ExpenseListViewModel
    @State private var showingFilters = false
    
    var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } else if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage).foregroundColor(.red)
                } else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRow(expense: expense)
                        }
                    }
                }
            }
            .navigationTitle("Expenses")
            .searchable(text: $viewModel.searchText, prompt: "Search expenses")
            .onChange(of: viewModel.searchText) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.selectedSortOption) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.startDate) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.endDate) { _, _ in viewModel.performSearch() }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Menu {
                        Section(header: Text("Sort By")) {
                            Picker("Sort By", selection: $viewModel.selectedSortOption) {
                                ForEach(ExpenseSortOption.allCases, id: \.self) { option in
                                    Text(option.rawValue).tag(option)
                                }
                            }
                        }
                        Section {
                            Button { showingFilters = true } label: { Label("Date Filters", systemImage: "line.3.horizontal.decrease.circle") }
                            if viewModel.startDate != nil || viewModel.endDate != nil {
                                Button(role: .destructive) { viewModel.clearDateFilter() } label: { Label("Clear Filters", systemImage: "xmark.circle") }
                            }
                        }
                    } label: { Image(systemName: "ellipsis.circle") }
                }
            }
            .sheet(isPresented: $showingFilters) {
                DateFilterSheet(
                    startDate: Binding(
                        get: { viewModel.startDate ?? Date().addingTimeInterval(-86400 * 30) },
                        set: { viewModel.startDate = $0 }
                    ),
                    endDate: Binding(
                        get: { viewModel.endDate ?? Date() },
                        set: { viewModel.endDate = $0 }
                    )
                )
            }
        }
    }
}

struct DateFilterSheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var startDate: Date
    @Binding var endDate: Date
    var body: some View {
        NavigationView {
            Form {
                DatePicker("Start Date", selection: $startDate, displayedComponents: .date)
                DatePicker("End Date", selection: $endDate, displayedComponents: .date)
            }
            .navigationTitle("Filter by Date")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } }
        }
    }
}

@Observable class ExpenseFRCViewModel: NSObject {
    private var fetchedResultsController: NSFetchedResultsController!
    func applyFilters(predicate: NSPredicate?, sortDescriptors: [NSSortDescriptor]) {
        NSFetchedResultsController.deleteCache(withName: "ExpenseCache")
        fetchedResultsController.fetchRequest.predicate = predicate
        fetchedResultsController.fetchRequest.sortDescriptors = sortDescriptors
        do {
            try fetchedResultsController.performFetch()
        } catch {
            print("Failed to re-fetch FRC: \\(error)")
        }
    }
}

3. Memory Management: Batch Size and Limits

  • fetchBatchSize: Tells Core Data to fetch the array of matching records, but keep them as lightweight "faults". Core Data will only hit the database to populate the object data in batches (e.g., 20 at a time) as the user scrolls through the UI.
  • fetchLimit: Hard caps the number of results returned (e.g., "Top 10 most expensive items").
import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
    func predicateOrdering() {
        // ❌ SLOW: Evaluates the expensive string comparison on ALL 100,000 records first.
        let slowPredicate = NSPredicate(format: "name CONTAINS[cd] %@ AND amount > 100", "coffee")
        
        // ✅ FAST: Filters out all expenses under $100 first (cheap). 
        // THEN evaluates the string comparison only on the remaining 500 records.
        let fastPredicate = NSPredicate(format: "amount > 100 AND name CONTAINS[cd] %@", "coffee")
    }
    func fetchBatching() {
        let request: NSFetchRequest = Expense.fetchRequest()
        request.fetchBatchSize = 20
        request.fetchLimit = 100 
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        Task { await fetchData() }
    }
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        var subpredicates: [NSPredicate] = []
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        if let start = startDate { subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate)) }
        if let end = endDate { subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate)) }
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        let descriptors = selectedSortOption.sortDescriptors
        do {
            let fetchedData = try await repository.fetchExpenses(predicate: finalPredicate, sortDescriptors: descriptors)
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
        }
        isLoading = false
    }
    func clearDateFilter() { startDate = nil; endDate = nil }
}

struct ExpenseRow: View {
    let expense: Expense
    var body: some View {
        Text(expense.name ?? "Unknown")
    }
}

struct ExpenseListView: View {
    @State var viewModel: ExpenseListViewModel
    @State private var showingFilters = false
    
    var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } else if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage).foregroundColor(.red)
                } else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRow(expense: expense)
                        }
                    }
                }
            }
            .navigationTitle("Expenses")
            .searchable(text: $viewModel.searchText, prompt: "Search expenses")
            .onChange(of: viewModel.searchText) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.selectedSortOption) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.startDate) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.endDate) { _, _ in viewModel.performSearch() }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Menu {
                        Section(header: Text("Sort By")) {
                            Picker("Sort By", selection: $viewModel.selectedSortOption) {
                                ForEach(ExpenseSortOption.allCases, id: \.self) { option in
                                    Text(option.rawValue).tag(option)
                                }
                            }
                        }
                        Section {
                            Button { showingFilters = true } label: { Label("Date Filters", systemImage: "line.3.horizontal.decrease.circle") }
                            if viewModel.startDate != nil || viewModel.endDate != nil {
                                Button(role: .destructive) { viewModel.clearDateFilter() } label: { Label("Clear Filters", systemImage: "xmark.circle") }
                            }
                        }
                    } label: { Image(systemName: "ellipsis.circle") }
                }
            }
            .sheet(isPresented: $showingFilters) {
                DateFilterSheet(
                    startDate: Binding(
                        get: { viewModel.startDate ?? Date().addingTimeInterval(-86400 * 30) },
                        set: { viewModel.startDate = $0 }
                    ),
                    endDate: Binding(
                        get: { viewModel.endDate ?? Date() },
                        set: { viewModel.endDate = $0 }
                    )
                )
            }
        }
    }
}

struct DateFilterSheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var startDate: Date
    @Binding var endDate: Date
    var body: some View {
        NavigationView {
            Form {
                DatePicker("Start Date", selection: $startDate, displayedComponents: .date)
                DatePicker("End Date", selection: $endDate, displayedComponents: .date)
            }
            .navigationTitle("Filter by Date")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } }
        }
    }
}

@Observable class ExpenseFRCViewModel: NSObject {
    private var fetchedResultsController: NSFetchedResultsController!
    func applyFilters(predicate: NSPredicate?, sortDescriptors: [NSSortDescriptor]) {
        NSFetchedResultsController.deleteCache(withName: "ExpenseCache")
        fetchedResultsController.fetchRequest.predicate = predicate
        fetchedResultsController.fetchRequest.sortDescriptors = sortDescriptors
        do {
            try fetchedResultsController.performFetch()
        } catch {
            print("Failed to re-fetch FRC: \\(error)")
        }
    }
}

4. Fetching Specific Properties (Dictionaries)

import Foundation
import CoreData
import Observation
import SwiftUI

@objc(Expense)
public class Expense: NSManagedObject, Identifiable {
    @NSManaged public var amount: Double
    @NSManaged public var name: String?
    @NSManaged public var date: Date?
    @NSManaged public var categoryName: String?
    @NSManaged public var isBusiness: Bool
    @NSManaged public var notes: String?
    
    @nonobjc public class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "Expense")
    }
}

class PredicateExamples {
    func simplePredicate() {
        let predicate = NSPredicate(format: "amount > %f", 50.0)
    }
    func collectionMembership() {
        let categories = ["Food", "Transport", "Utilities"]
        let predicate = NSPredicate(format: "categoryName IN %@", categories)
    }
    func dynamicPredicates() {
        let searchString = "Trader Joe's" 
        let badPredicate = NSPredicate(format: "name CONTAINS '\\(searchString)'") // Crash!
        let goodPredicate = NSPredicate(format: "name CONTAINS %@", searchString)
    }
    func dynamicKeyPaths() {
        let userSelectedProperty = "notes"
        let selectedProperty = userSelectedProperty // e.g., "notes"
        let searchString = "Coffee"
        let bad = NSPredicate(format: "%@ CONTAINS %@", selectedProperty, searchString)
        let good = NSPredicate(format: "%K CONTAINS %@", selectedProperty, searchString)
    }
    func compoundPredicate() {
        let oneWeekAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
        let amountPredicate = NSPredicate(format: "amount > %f", 50.0)
        let datePredicate = NSPredicate(format: "date >= %@", oneWeekAgo as NSDate)
        let businessPredicate = NSPredicate(format: "isBusiness == NO")
        let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
            amountPredicate, 
            datePredicate, 
            businessPredicate
        ])
    }
    func sortDescriptors() {
        let sortByDateNewest = NSSortDescriptor(key: "date", ascending: false)
        let sortByAmountHighest = NSSortDescriptor(key: "amount", ascending: false)
    }
    func multipleSortDescriptors() {
        let fetchRequest: NSFetchRequest = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "amount", ascending: false),
            NSSortDescriptor(key: "date", ascending: false)
        ]
    }
    func localizedSorting() {
        let naturalSort = NSSortDescriptor(
            key: "name", 
            ascending: true, 
            selector: #selector(NSString.localizedStandardCompare(_:))
        )
    }
    func predicateOrdering() {
        // ❌ SLOW: Evaluates the expensive string comparison on ALL 100,000 records first.
        let slowPredicate = NSPredicate(format: "name CONTAINS[cd] %@ AND amount > 100", "coffee")
        
        // ✅ FAST: Filters out all expenses under $100 first (cheap). 
        // THEN evaluates the string comparison only on the remaining 500 records.
        let fastPredicate = NSPredicate(format: "amount > 100 AND name CONTAINS[cd] %@", "coffee")
    }
    func fetchBatching() {
        let request: NSFetchRequest = Expense.fetchRequest()
        request.fetchBatchSize = 20
        request.fetchLimit = 100 
    }
    func dictionaryResults() {
        let request = NSFetchRequest(entityName: "Expense")
        request.resultType = .dictionaryResultType
        request.propertiesToFetch = ["name", "amount"]
        // Returns [[String: Any]] instead of [Expense]
    }
}

protocol ExpenseRepositoryProtocol {
    func fetchExpenses(
        predicate: NSPredicate?,
        sortDescriptors: [NSSortDescriptor]?
    ) async throws -> [Expense]
}

class ExpenseRepository: ExpenseRepositoryProtocol {
    let context: NSManagedObjectContext
    init(context: NSManagedObjectContext) { self.context = context }
    func fetchExpenses(
        predicate: NSPredicate? = nil,
        sortDescriptors: [NSSortDescriptor]? = nil
    ) async throws -> [Expense] {
        return try await withCheckedThrowingContinuation { continuation in
            let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
            backgroundContext.persistentStoreCoordinator = self.context.persistentStoreCoordinator
            backgroundContext.perform {
                let request: NSFetchRequest = Expense.fetchRequest()
                request.predicate = predicate
                if let sortDescriptors = sortDescriptors, !sortDescriptors.isEmpty {
                    request.sortDescriptors = sortDescriptors
                } else {
                    request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
                }
                do {
                    let backgroundExpenses = try backgroundContext.fetch(request)
                    let objectIDs = backgroundExpenses.map { $0.objectID }
                    self.context.perform {
                        let mainThreadExpenses = objectIDs.compactMap { self.context.object(with: $0) as? Expense }
                        continuation.resume(returning: mainThreadExpenses)
                    }
                } catch { continuation.resume(throwing: error) }
            }
        }
    }
}

enum ExpenseSortOption: String, CaseIterable {
    case dateNewest = "Date (Newest First)"
    case dateOldest = "Date (Oldest First)"
    case amountHighest = "Amount (High to Low)"
    case amountLowest = "Amount (Low to High)"
    
    var sortDescriptors: [NSSortDescriptor] {
        switch self {
        case .dateNewest: return [NSSortDescriptor(key: "date", ascending: false)]
        case .dateOldest: return [NSSortDescriptor(key: "date", ascending: true)]
        case .amountHighest: return [NSSortDescriptor(key: "amount", ascending: false), NSSortDescriptor(key: "date", ascending: false)]
        case .amountLowest: return [NSSortDescriptor(key: "amount", ascending: true), NSSortDescriptor(key: "date", ascending: false)]
        }
    }
}

@MainActor
@Observable class ExpenseListViewModel {
    var expenses: [Expense] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var searchText: String = ""
    var selectedSortOption: ExpenseSortOption = .dateNewest
    var startDate: Date? = nil
    var endDate: Date? = nil
    private let repository: ExpenseRepositoryProtocol
    private var searchTask: Task?
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        Task { await fetchData() }
    }
    func performSearch() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchData()
        }
    }
    func fetchData() async {
        isLoading = true
        errorMessage = nil
        var subpredicates: [NSPredicate] = []
        let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedSearch.isEmpty {
            let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", trimmedSearch)
            subpredicates.append(searchPredicate)
        }
        if let start = startDate { subpredicates.append(NSPredicate(format: "date >= %@", start as NSDate)) }
        if let end = endDate { subpredicates.append(NSPredicate(format: "date <= %@", end as NSDate)) }
        let finalPredicate = subpredicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
        let descriptors = selectedSortOption.sortDescriptors
        do {
            let fetchedData = try await repository.fetchExpenses(predicate: finalPredicate, sortDescriptors: descriptors)
            self.expenses = fetchedData
        } catch {
            self.errorMessage = "Failed to load expenses. Please try again."
        }
        isLoading = false
    }
    func clearDateFilter() { startDate = nil; endDate = nil }
}

struct ExpenseRow: View {
    let expense: Expense
    var body: some View {
        Text(expense.name ?? "Unknown")
    }
}

struct ExpenseListView: View {
    @State var viewModel: ExpenseListViewModel
    @State private var showingFilters = false
    
    var body: some View {
        NavigationView {
            ZStack {
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } else if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage).foregroundColor(.red)
                } else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRow(expense: expense)
                        }
                    }
                }
            }
            .navigationTitle("Expenses")
            .searchable(text: $viewModel.searchText, prompt: "Search expenses")
            .onChange(of: viewModel.searchText) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.selectedSortOption) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.startDate) { _, _ in viewModel.performSearch() }
            .onChange(of: viewModel.endDate) { _, _ in viewModel.performSearch() }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Menu {
                        Section(header: Text("Sort By")) {
                            Picker("Sort By", selection: $viewModel.selectedSortOption) {
                                ForEach(ExpenseSortOption.allCases, id: \.self) { option in
                                    Text(option.rawValue).tag(option)
                                }
                            }
                        }
                        Section {
                            Button { showingFilters = true } label: { Label("Date Filters", systemImage: "line.3.horizontal.decrease.circle") }
                            if viewModel.startDate != nil || viewModel.endDate != nil {
                                Button(role: .destructive) { viewModel.clearDateFilter() } label: { Label("Clear Filters", systemImage: "xmark.circle") }
                            }
                        }
                    } label: { Image(systemName: "ellipsis.circle") }
                }
            }
            .sheet(isPresented: $showingFilters) {
                DateFilterSheet(
                    startDate: Binding(
                        get: { viewModel.startDate ?? Date().addingTimeInterval(-86400 * 30) },
                        set: { viewModel.startDate = $0 }
                    ),
                    endDate: Binding(
                        get: { viewModel.endDate ?? Date() },
                        set: { viewModel.endDate = $0 }
                    )
                )
            }
        }
    }
}

struct DateFilterSheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var startDate: Date
    @Binding var endDate: Date
    var body: some View {
        NavigationView {
            Form {
                DatePicker("Start Date", selection: $startDate, displayedComponents: .date)
                DatePicker("End Date", selection: $endDate, displayedComponents: .date)
            }
            .navigationTitle("Filter by Date")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } }
        }
    }
}

@Observable class ExpenseFRCViewModel: NSObject {
    private var fetchedResultsController: NSFetchedResultsController!
    func applyFilters(predicate: NSPredicate?, sortDescriptors: [NSSortDescriptor]) {
        NSFetchedResultsController.deleteCache(withName: "ExpenseCache")
        fetchedResultsController.fetchRequest.predicate = predicate
        fetchedResultsController.fetchRequest.sortDescriptors = sortDescriptors
        do {
            try fetchedResultsController.performFetch()
        } catch {
            print("Failed to re-fetch FRC: \\(error)")
        }
    }
}

Summary

  • You learned the syntax for building predicates safely using variable substitution (%@) and dynamic key paths (%K), avoiding predicate injection.
  • You composed complex filters using NSCompoundPredicate.
  • You implemented a highly robust MVVM architecture utilizing iOS 17 @Observable and Modern Concurrency for debouncing input, and background concurrency (performBackgroundTask) to keep the UI perfectly smooth.
  • Finally, you learned professional-grade performance optimizations including indexing, predicate ordering, batching, and cache invalidation.