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.%fis the substitution variable for a floating-point number.50.0is the argument that gets safely substituted into the%fplaceholder.
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)
}
}
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
])
}
}
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:, andlocalizedStandardCompare:. 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
- Search for expenses by name.
- Filter by a specific date range.
- 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
fetchRequestof an existingNSFetchedResultsController, you are pulling an entirely new subset of data. If you initialized your FRC with acacheName, the existing cache represents the old data. You must callNSFetchedResultsController.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
- Open your
.xcdatamodeldfile. - Select the
Expenseentity. - Select the
dateattribute. - 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
@Observableand 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.