#Predicate needs better validation

Hi,

Overview

  • I am finding #Predicate to be a bit tricky when used with Swift Data
  • It compiles fine but crashes at runtime
  • I know the fix for the problem just wondering if such pitfalls can be avoided at compile time

Exception

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'can't use NULL on left hand side'
terminating due to uncaught exception of type NSException

CoreData: error: SQLCore dispatchRequest: exception handling request: <NSSQLFetchRequestContext: 0x11209b000> , can't use NULL on left hand side with userInfo of (null)

Questions

  • Could anything be done to improve the safety to avoid such issues at runtime?
  • Could I write the code better (better than the fix below) to avoid this?

My thoughts

  • Fix is possible however it wasn't obvious to me that there was a problem with my original code
  • Would be nice to prevent them at compile time if possible.
  • Currently got to be really careful to avoid such crashes.

Code

import Foundation
import SwiftData

@Model
class Car {
    var name: String
    var modelRawValue: String?
    
    init(name: String, modelRawValue: String?) {
        self.name = name
        self.modelRawValue = modelRawValue
    }
}

enum CarModel: String, CaseIterable {
    case modelA
    case modelB
}


func makePredicate(filterModels: [CarModel]?) -> Predicate<Car> {
    let filterRawValues = filterModels?.map { $0.rawValue }
    
    let predicate = #Predicate<Car> { car in
        if let filterRawValues {
            if let carModelRawValue = car.modelRawValue {
                filterRawValues.contains(carModelRawValue)
            } else {
                false
            }
        } else {
            true
        }
    }
    
    return predicate
}

func fetch(context: ModelContext) throws {
    let predicate = makePredicate(filterModels: nil)
    let fetchDescriptor = FetchDescriptor(predicate: predicate)
    
    do {
        let cars = try context.fetch(fetchDescriptor)
        print(cars.count)
    } catch {
        print("Error: \(error)")
        throw error
    }
}

Fix

func makePredicate(filterModels: [CarModel]?) -> Predicate<Car> {
    // Checking nil condition even before creating the predicate fixes the issue
    guard let filterModels else { return .true }

    let filterRawValues = filterModels.map { $0.rawValue }
    
    let predicate = #Predicate<Car> { car in
        if let carModelRawValue = car.modelRawValue {
            filterRawValues.contains(carModelRawValue)
        } else {
            false
        }
    }
    
    return predicate
}
Answered by DTS Engineer in 902986022

I'd say that the fix you did is exactly right.

Basically, when you write #Predicate<Car> with a Swift closure, the macro converts the closure into a tree of PredicateExpressions, which is then converted to an NSPrediccate in Core Data, and filterRawValues.contains(...) becomes an expression of the predicate.

In runtime, the system doesn't really run the Swift code in the closure to perform dynamic branch elimination on captured variables. Instead, it passes the value of filterRawValues to the NSPrediate. When the value is nil, Core Data throws the error because it doesn't allow NULL on the left-hand side of an expression.

Moving if let filterRawValues outside of the #predicate macro avoids running the macro when filterRawValues is nil, hence no error.

I'd go further to make it a rule: Inside the closure of #Predicate, use if let only for unwrapping optional persistent properties of the model; move external parameter branching in Swift outside of the macro.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I'd say that the fix you did is exactly right.

Basically, when you write #Predicate<Car> with a Swift closure, the macro converts the closure into a tree of PredicateExpressions, which is then converted to an NSPrediccate in Core Data, and filterRawValues.contains(...) becomes an expression of the predicate.

In runtime, the system doesn't really run the Swift code in the closure to perform dynamic branch elimination on captured variables. Instead, it passes the value of filterRawValues to the NSPrediate. When the value is nil, Core Data throws the error because it doesn't allow NULL on the left-hand side of an expression.

Moving if let filterRawValues outside of the #predicate macro avoids running the macro when filterRawValues is nil, hence no error.

I'd go further to make it a rule: Inside the closure of #Predicate, use if let only for unwrapping optional persistent properties of the model; move external parameter branching in Swift outside of the macro.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks a lot @DTS Engineer Ziqiao Chen for that clear explanation.

The last sentence you mention is key and very valid:

I'd go further to make it a rule: Inside the closure of #Predicate, use if let only for unwrapping optional persistent properties of the model; move external parameter branching in Swift outside of the macro.

I am no expert but given below are my thoughts:

  • Predicate can be used in SwiftData and outside SwiftData for filtering.
  • There is a problem with this approach there are some restrictions with using it in SwiftData with which you pointed out and many more that aren't allowed.
  • I feel it is better to provide another a predicate type (sample name: DataPredicate) specifically for SwiftData.
  • This would be better because DataPredicate can have more restrictions at compile time by not allowing optional values and other rules.
  • The current approach often catches most programmers off guard primarily because on the surface it offers some compile time checks and lacks some which are caught at runtime.
  • Separate type would allow Apple to add more safety to DataPredicate to allow only what can be translated into SQL / valid commands under the hood at the same time keeping Predicate as it is.

On a side note:

  • Please provide feature parity with NSPredicate, currently Predicate doesn't support regex
  • is there a way / workaround to use regex in Swift Data predicate?

@DTS Engineer

Is there any documentation on what kind of predicates are not supported by Swift Data?

I discovered a new crash:

crashed due to an uncaught exception `NSInvalidArgumentException`. Reason: Unsupported function expression TERNARY(department != nil, department.school, nil).identifier.

I am really worried as I don't know if other crashes would only discovered at runtime.

At least with NSPredicate we knew we were dealing with SQL, now Predicate tries to mask that and it fails at runtime.

My suggestion:

  • Please have some proper documentation of what kind of predicate is allowed and what is not allowed in Swift Data
  • Please use compiler validation or build a completely different type
  • The approach of using a generic predicate allowing all valid swift statements inside a predicate falls short at runtime.
  • Current Predicate implementation gives a false sense of security
#Predicate needs better validation
 
 
Q