swiftpredicateswiftdata

Predicate expression not compiling because of complexity


I am displaying quite a lot of data in a SwiftUI view, the data is stored in SwiftData and it's working fine, the problem is when I need to filter the data. Below code is a simpler version of the filters I want to use, I've made it simple so that it's easy for y'all to understand. The below code(simpler version) took 21 seconds to build.

The below is the SwiftUI view which displays some data stored in SwiftData:

struct TestView: View {
    @Environment(\.modelContext)    var modelContext
    @Query(sort: \GroceryItem.name) var groceryItems: [GroceryItem]
    
    init(sort: SortDescriptor<GroceryItem>, searchText: String, stores: Set<String>) {
        _groceryItems = Query(filter: #Predicate {
            // The below expression takes a little while to compile
            if searchText.isEmpty {
                if stores.isEmpty {
                    return true
                } else {
                    return stores.contains($0.store)
                }
            } else {
                if stores.isEmpty {
                    return $0.name.localizedStandardContains(searchText)
                } else {
                    return stores.contains($0.store) && $0.name.localizedStandardContains(searchText)
                }
            }

        } , sort: [sort])
    }
    
    var body: some View {
        List(groceryItems) { item in
            GroceryItemCell(groceryItem: item, isAddGroceryItemView: true) { _ in
                print("Update total price")
            }
            .listRowInsets(EdgeInsets())
        }
    }
}

This initialiser is being called from another view like to display the stored data in SwiftData like so:

TestView(sort: sortOrder, searchText: searchText, stores: selectedStores)

Code explanation: The initialiser takes in 3 parameters:

sort -> By default it is SortDescriptor(\GroceryItem.name), this just sorts the items in the array by order of name searchText -> This is the text from the .searchable modifier, it filters the array items according to what the user types stores -> @State private var selectedStores = Set<String>(), this is the Set of stores that are selected by the user. The initialiser in the TestView filters the array according to the selected stores.

Here's the GroceryItem model properties:

@Model
class GroceryItem: Codable, Identifiable { 
    var id : String? 
    var store  : String 
    var category : String? 
    var subCategory  : String? 
    var name : String 
    var itemDescription : [DescriptionItem]? 
    var price  : String? 
    var pricePerType : String? 
    var image  : String? 
    var url  : String? 
    var isItemSelected : Bool? 
    var quantity = 1
}

The code is working fine but it takes too long to build the project. Like I mentioned, this is the simpler version of the code, the complex version doesn't even build, it gives this error: The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions is there any way I could use a different approach to set the filters?

Here's the complex version of the code:

init(sort: SortDescriptor<GroceryItem>, searchText: String, stores: Set<String>, categories: Set<String>, subCategories: Set<String>) {
        _groceryItems = Query(filter: #Predicate {
            if searchText.isEmpty {
                if stores.isEmpty {
                    if categories.isEmpty {
                        if subCategories.isEmpty {
                            return true
                        } else {
                            return subCategories.contains($0.subCategory!)
                        }
                    } else {
                        if subCategories.isEmpty {
                            return categories.contains($0.category!)
                        } else {
                            return categories.contains($0.category!) && subCategories.contains($0.subCategory!)
                        }
                    }
                } else {
                    if categories.isEmpty {
                        if subCategories.isEmpty {
                            return true
                        } else {
                            return subCategories.contains($0.subCategory!)
                        }
                    } else {
                        if subCategories.isEmpty {
                            return categories.contains($0.category!)
                        } else {
                            return categories.contains($0.category!) && subCategories.contains($0.subCategory!)
                        }
                    }
                }
            } else {
                if stores.isEmpty {
                    if categories.isEmpty {
                        if subCategories.isEmpty {
                            return $0.name.localizedStandardContains(searchText)
                        } else {
                            return $0.name.localizedStandardContains(searchText) && subCategories.contains($0.subCategory!)
                        }
                    } else {
                        if subCategories.isEmpty {
                            return $0.name.localizedStandardContains(searchText) && categories.contains($0.category!)
                        } else {
                            return $0.name.localizedStandardContains(searchText) && categories.contains($0.category!) && subCategories.contains($0.subCategory!)
                        }
                    }
                } else {
                    if categories.isEmpty {
                        if subCategories.isEmpty {
                            return $0.name.localizedStandardContains(searchText) && stores.contains($0.store)
                        } else {
                            return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && subCategories.contains($0.subCategory!)
                        }
                    } else {
                        if subCategories.isEmpty {
                            return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && categories.contains($0.category!)
                        } else {
                            return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && categories.contains($0.category!) && subCategories.contains($0.subCategory!)
                        }
                    }
                }
            }
        }, sort: [sort])
    }

Solution

  • You should put as little code in #Predicate { ... } as possible. The #Predicate macro parses the code in the closure and expands to a "tree" of calls of the build_XXX methods in PredicateExpressions. The error is basically saying this "tree" is too complicated.

    The deeply nested if in your code seems to boil down to the condition:

    (searchText.isEmpty || foo.name.localizedStandardContains(searchText)) &&
    (stores.isEmpty || stores.contains(foo.store)) &&
    (categories.isEmpty || categories.contains(foo.category!)) &&
    (subCategories.isEmpty || subCategories.contains(foo.subCategory!))
    

    On my machine, this is still too complicated for the compiler to handle, and we can extract all the xxx.isEmpty out, as let constants:

    let searchTextEmpty = searchText.isEmpty
    let storesEmpty = stores.isEmpty
    let categoriesEmpty = categories.isEmpty
    let subCategoriesEmpty = subCategories.isEmpty
    let predicate = #Predicate<GroceryItem> { foo in
        (searchTextEmpty || foo.name.localizedStandardContains(searchText)) &&
        (storesEmpty || stores.contains(foo.store)) &&
        (categoriesEmpty || categories.contains(foo.category!)) &&
        (subCategoriesEmpty || subCategories.contains(foo.subCategory!))
    }
    

    This now compiles in a reasonable period of time.

    You should not force unwrap category and subCategory if it is possible for them to be nil. I would instead create Set<String?> from the Set<String>s.

    let optionalCategories = categories.map(Optional.some)
    let optionalSubCategories = subCategories.map(Optional.some)
    let searchTextEmpty = searchText.isEmpty
    let storesEmpty = stores.isEmpty
    let categoriesEmpty = optionalCategories.isEmpty
    let subCategoriesEmpty = optionalSubCategories.isEmpty
    let predicate = #Predicate<GroceryItem> { foo in
        (searchTextEmpty || foo.name.localizedStandardContains(searchText)) &&
        (storesEmpty || stores.contains(foo.store)) &&
        (categoriesEmpty || optionalCategories.contains(foo.category)) &&
        (subCategoriesEmpty || optionalSubCategories.contains(foo.subCategory))
    }
    

    Hopefully in future versions of Swift Data, the #Predicate macro doesn't expand to a single giant "tree" of method calls that takes ages to type check, and instead declare let constants to break the expression down.