Search code examples
swiftcore-dataswift-keypath

Create NSPredicate using generic KeyPath<Root, Value>


This is a simplified version of my code but I'd like to be able to initialise an NSPredicate from a generic KeyPath<Root, Value> but despite persistent attempts I've fallen short. Can anyone help? (solution needs to be iOS 15 compatible for my needs).

PS: I am aware I could alter the method to take a key: String and have callers use #keypath(Object.property) at the call-site but I do not want to do that, as I want to enforce a KeyPath is used for compile safety.

Attempt 1

func makeNSPredicate<Root, Value>(keyPath: KeyPath<Root,Value>, value: Value) -> NSPredicate {
    NSPredicate(format: "%K == '\(value)'", argumentArray: [keyPath])
}

Runtime error: signal SIGABRT

Attempt 2

func makeNSPredicate<Root, Value>(keyPath: KeyPath<Root,Value>, value: Value) -> NSPredicate {
    NSPredicate(format: "%K == '\(value)'", keyPath)
}

Compile error: Argument type 'KeyPath<Root, Value>' does not conform to expected type 'CVarArg'

Attempt 3

func makeNSPredicate<Root, Value>(keyPath: KeyPath<Root,Value>, value: Value) -> NSPredicate {
    NSPredicate(format: "%K == '\(value)'", #keyPath(keyPath))
}

Compile error: Argument of '#keyPath' refers to non-'@objc' property 'keyPath'


Solution

  • You can convert a Predicate to NSPredicate. This doesn't work with every Predicate, but assuming your are only comparing key paths visible to Objective-C, and their values are also representable in Objective-C, this works. See the documentation for what kind of Predicates cannot be converted to NSPredicate.

    func makeNSPredicate<Root: NSObject, Value: Equatable & Codable>(keyPath: KeyPath<Root,Value>, value: Value) -> NSPredicate {
        NSPredicate(Predicate<Root> { x in
            PredicateExpressions.build_Equal(
                lhs: PredicateExpressions.build_KeyPath(
                    root: PredicateExpressions.build_Arg(x),
                    keyPath: keyPath
                ),
                rhs: PredicateExpressions.build_Arg(value)
            )
        })!
    }
    

    Though Predicate is often used in a SwiftData context, you don't need to import SwiftData to use it. It is actually part of Foundation.

    Note that Value here needs to be Codable, so you might have to implement Codable for some of the types that you are comparing.


    For lower iOS versions, you can get the key path string from a KeyPath, and substitute that in with %K. The downside is that you need a different format specifier for each type of value. You cannot use string interpolation here, like '\(value)'. That won't work with numeric types.

    Here are two examples - Int and String

    func makeNSPredicate<Root>(keyPath: KeyPath<Root, Int>, value: Int) -> NSPredicate {
        NSPredicate(format: "%K == %lld", NSExpression(forKeyPath: keyPath).keyPath, value)
    }
    
    func makeNSPredicate<Root>(keyPath: KeyPath<Root, String>, value: String) -> NSPredicate {
        NSPredicate(format: "%K == %@", NSExpression(forKeyPath: keyPath).keyPath, value)
    }