SwiftData relationshipKeyPathsForPrefetching not working

relationshipKeyPathsForPrefetching in SwiftData does not seem to work here when scrolling down the list. Why?

I would like all categories to be fetched while posts are fetched - not while scrolling down the list.

struct ContentView: View {
    var body: some View {
        QueryList(
            fetchDescriptor: withCategoriesFetchDescriptor
        )
    }
    
    var withCategoriesFetchDescriptor: FetchDescriptor<Post> {
        var fetchDescriptor = FetchDescriptor<Post>()
        fetchDescriptor.relationshipKeyPathsForPrefetching = [\.category]
        return fetchDescriptor
    }
}
struct QueryList: View {        
    @Query
    var posts: [Post]

    init(fetchDescriptor: FetchDescriptor<Post>) {
        _posts = Query(fetchDescriptor)
    }

    var body: some View {
        List(posts) { post in
            VStack {
                Text(post.title)
                Text(post.category?.name ?? "")
                    .font(.footnote)
            }
        }
    }
}

@Model
final class Post {
    var title: String
    var category: Category?
    
    init(title: String) {
        self.title = title
    }
}

@Model final class Category {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

By using -com.apple.CoreData.SQLDebug 1, I do see that, although the system indeed does prefetching, accessing an attribute of a prefetched relationship still triggers a fetch, which seems to be unnecessary.

I’d suggest that you file a feedback report – If you do so, please share your report ID here.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

FB16366153 (SwiftData relationshipKeyPathsForPrefetching not working)

I also filed one for the same issue: FB16858906

In addition, prefetching related models will also prefetch pks for any one-to-many models in the related model which is unexpected and not desired.

Given:

@Model final class OrderItem {
    var quantity: Int
    var sku: Int
    var order: Order? = nil
     
     init(quantity: Int, sku: Int) {
        self.quantity = quantity
        self.sku = sku
    }
}

@Model final class Order {
    var orderID: Int
    var timestamp: Date = Date()
    var account: Account?
    @Relationship(deleteRule: .cascade, inverse: \OrderItem.order)
    var orderItems: [OrderItem]? = []
    
    init(orderID: Int) { self.orderID = orderID }
}

@Model final class Account {
    var accountID: Int
    @Relationship(deleteRule: .cascade, inverse: \Order.account)    
    var orders: [Order]? = []
    
    init(accountID: Int) { self.accountID = accountID }
}

With some sample data:

let account = Account(accountID: 1)
modelContext.insert(account)

let order = Order(orderID: 100)
modelContext.insert(order)
order.account = account

let orderItem = OrderItem(quantity: 1, sku: 999)
modelContext.insert(orderItem)
orderItem.order = order

Trying to fetch Accounts and pre-fetch related Orders results in 5 queries rather than 2:

var fd = FetchDescriptor<Account>()
fd.relationshipKeyPathsForPrefetching = [\.orders]
let accounts = modelContext.fetch(fd)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZACCOUNTID FROM ZACCOUNT t0 
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT FROM ZORDER t0 WHERE  t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0)  ORDER BY t0.ZACCOUNT
// CoreData: sql: SELECT 0, t0.Z_PK FROM ZORDERITEM t0 WHERE  t0.ZORDER = ? 

for account in accounts {
    if let orders = account.orders {
        for order in orders {
            let orderID = order.orderID
            // CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT FROM ZORDER t0 WHERE  t0.Z_PK IN  (?)  
            // CoreData: sql: SELECT 0, t0.Z_PK FROM ZORDERITEM t0 WHERE  t0.ZORDER = ? 
        }
    }
}

It looks like I can work around part of the problem by executing the fetch for the related models directly. SwiftData will then properly use them as a cache.

// Fetch accounts
var fd = FetchDescriptor<Account>()
let accounts = modelContext.fetch(fd)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZACCOUNTID FROM ZACCOUNT t0 
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT, t0.ZORDERITEMS FROM ZORDER t0 WHERE  t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0)  ORDER BY t0.ZACCOUNT

// Fetch related orders for accounts
let accArray = accounts.map { $0.persistentModelID }
var fd2 = FetchDescriptor<Order>()
fd2.predicate = #Predicate {                
    if let account = $0.account {                    
        accArray.contains(account.persistentModelID)
    } else {
        false
    }
}

let orders = try? modelContext.fetch(fd2)
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZORDERID, t0.ZTIMESTAMP, t0.ZACCOUNT, t0.ZORDERITEMS FROM ZORDER t0 WHERE (CASE ((CASE ( t0.ZACCOUNT IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IN (SELECT * FROM _Z_intarray0) ) when 1 then (?) else (?) end)) else (?) end) IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IS NOT NULL) when 1 then ((CASE ( t0.ZACCOUNT IN (SELECT * FROM _Z_intarray1) ) when 1 then (?) else (?) end)) else (?) end)) else (?) end) = ? 
// + 1 query to OrderItem per result if not using an intermediary model

for account in accounts {
    if let orders = account.orders {
        for order in orders {
            // this will use the cached order fetched above so no queries will execute
            let orderID = order.orderID
        }
    }
}

To keep SwiftData from executing a query for each Order to get the PKs for each OrderItem though seems to require replacing the 1-to-many relationship with a 1-to-1 relationship with an intermediary model since SwiftData only pre-fetches on to-many relationships. This isn't particularly ergonomic, but it seems to work.

SwiftData relationshipKeyPathsForPrefetching not working
 
 
Q