0

I'm working with SwiftData and trying to share logic across multiple models using protocols and protocol extensions.

I’ve created some common protocols like Queryable, StatusRepresentable, and Trackable, which my SwiftData models (e.g., Pet) conform to.

My model looks like this:

@Model
final class Pet {
    var id: UUID
    var name: String
    var statusRaw: String
    // ... other properties
}

And I define these protocols:

protocol StatusRepresentable: AnyObject, PersistentModel {
    var statusRaw: String { get set }
}

extension StatusRepresentable {
    var status: Status {
        get { Status(rawValue: statusRaw) ?? .active }
        set { statusRaw = newValue.rawValue }
    }

    func changeStatus(to newStatus: Status) {
        if newStatus != status {
            self.updateTimestamp(onChange: newStatus)
            self.statusRaw = newStatus.rawValue
        }
    }
}

And:

protocol Queryable: AnyObject, Identifiable, StatusRepresentable, PersistentModel {}

extension Queryable {
    static var activePredicate: Predicate<Self> {
        .withStatus(.active)
    }

    static func predicate(for id: UUID) -> Predicate<Self> where Self.ID == UUID {
        .withId(id)
    }
}

Here's the problematic part:

I’m using a generic predicate extension like this:

extension Predicate {
    static func withStatus<T: Queryable>(_ status: Status...) -> Predicate<T> {
        let rawValues = status.map { $0.rawValue }
        return #Predicate<T> {
            rawValues.contains($0.statusRaw)
        }
    }
}

Then in my SwiftUI View, I use it like so:

struct ComponentActiveList: View {
    @Query private var activePets: [Pet]

    init() {
        self._activePets = Query(
            filter: .activePredicate, // or .withStatus(.active)
            sort: \.name,
            order: .forward
        )
    }

    var body: some View {
        // ...
    }
}

The problem:

It compiles fine, but crashes at runtime with this error (simplified):

keyPath: \.statusRaw
Thread 1: EXC_BREAKPOINT (code=1, subcode=0x...)

In the expanded macro, I can see this:

Foundation.Predicate<T>({
    PredicateExpressions.build_contains(
        PredicateExpressions.build_Arg(rawValues),
        PredicateExpressions.build_KeyPath(
            root: PredicateExpressions.build_Arg($0),
            keyPath: \.statusRaw
        )
    )
})

It seems like the macro is having trouble resolving \.statusRaw via protocol extension / dynamic lookup. I'm guessing this has something to do with SwiftData + `#Predicate being unable to resolve protocol-constrained properties at runtime?


Before introducing protocols like Queryable and StatusRepresentable, I had this working by duplicating the predicate logic for each model individually - for example:

extension Predicate {
    static func pets(with status: Status...) -> Predicate<Pet> {
        let rawValues = status.map { $0.rawValue }
        return #Predicate<Pet> {
            rawValues.contains($0.statusRaw)
        }
    }

    static func pet(with id: UUID) -> Predicate<Pet> {
        #Predicate<Pet> { $0.id == id }
    }
}

As a workaround, I’ve currently reverted all the protocol code and am duplicating the predicate logic for each model directly. But ideally, I’d like to define these in one place via protocols or generics.

1 Answer 1

0

On Xcode 26 beta 3, I observed that the type of \.statusRaw is an "invalid type", by writing:

extension Predicate {
    static func withStatus<T: Queryable>(_ status: Status...) -> Predicate<T> {
        let rawValues = status.map { $0.rawValue }
        return Foundation.Predicate<T>({
            PredicateExpressions.build_contains(
                PredicateExpressions.build_Arg(rawValues),
                PredicateExpressions.build_KeyPath(
                    root: PredicateExpressions.build_Arg($0),
                    keyPath: printType(\.statusRaw) // here it prints "invalid type"
                )
            )
        })
    }
}

func printType<T>(_ x: T) -> T {
    print(type(of: x))
    return x
}

I don't know exactly why this happens, but I have found that this works:

extension Queryable {
    static var statusRawKeyPath: any KeyPath<Self, String> & Sendable { \.statusRaw }
}

extension Predicate {
    static func withStatus<T: Queryable>(_ status: Status...) -> Predicate<T> {
        let rawValues = status.map { $0.rawValue }
        return Foundation.Predicate<T>({
            PredicateExpressions.build_contains(
                PredicateExpressions.build_Arg(rawValues),
                PredicateExpressions.build_KeyPath(
                    root: PredicateExpressions.build_Arg($0),
                    keyPath: T.statusRawKeyPath
                )
            )
        })
    }
}

All I did was to avoid using a key path literal in withStatus. This suggests that this could be a problem with the compiler.

Full example code:

struct ContentView: View {
    @Query(filter: .withStatus(.active), sort: \Pet.name)
    private var activePets: [Pet]
    @Environment(\.modelContext) var context
    
    var body: some View {
        Text("active pets: \(activePets.count)")
            .onAppear {
                let pet = Pet(id: .init(), name: "Foo", statusRaw: "active")
                context.insert(pet)
                try! context.save()
            }
    }
}

@Model
final class Pet: Queryable {
    var id: UUID
    var name: String
    var statusRaw: String
    
    init(id: UUID, name: String, statusRaw: String) {
        self.id = id
        self.name = name
        self.statusRaw = statusRaw
    }
}

protocol StatusRepresentable: AnyObject, PersistentModel {
    var statusRaw: String { get set }
}

extension StatusRepresentable {
    var status: Status {
        get { Status(rawValue: statusRaw) ?? .active }
        set { statusRaw = newValue.rawValue }
    }

    func changeStatus(to newStatus: Status) {
        if newStatus != status {
            self.statusRaw = newStatus.rawValue
        }
    }
}

enum Status: String {
    case active, inactive
}

protocol Queryable: AnyObject, Identifiable, StatusRepresentable, PersistentModel {}

extension Queryable {
    static var activePredicate: Predicate<Self> {
        .withStatus(.active)
    }
    
    static var statusRawKeyPath: any KeyPath<Self, String> & Sendable { \.statusRaw }
}

extension Predicate {
    static func withStatus<T: Queryable>(_ status: Status...) -> Predicate<T> {
        let rawValues = status.map { $0.rawValue }
        return Foundation.Predicate<T>({
            PredicateExpressions.build_contains(
                PredicateExpressions.build_Arg(rawValues),
                PredicateExpressions.build_KeyPath(
                    root: PredicateExpressions.build_Arg($0),
                    keyPath: T.statusRawKeyPath
                )
            )
        })
    }
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.