Search code examples
swiftswiftdata

How can I best reuse Predicate logic outside a macro?


I am using a compound predicate to filter Items for a View based on a date.

let predicate = #Predicate<Item> { item in
            createdOnDay.evaluate(item)
            ||
            (isStartEnd.evaluate(item) && startEndShowsOnDay.evaluate(item))
        }

Some of the predicates evaluated are themselves fairly complex. I have written a function to generate a predicate:

static func startEndAppliesDuring(_ range: ClosedRange<Date>) -> Predicate<Item>
    {
        let distantFuture = Date.distantFuture
        
        return #Predicate<Item> { item in
            ((item.startDate ?? item.endDate ?? distantFuture) < range.upperBound)
            &&
            ((item.endDate ?? item.startDate!) >= range.lowerBound)
        }
    }

...which I call from the View before evaluating with others:

let startEndShowsOnDay = Item.startEndAppliesDuring(dayRange)

Ideally, I would like to reuse the date logic inside the predicate macro, which takes a bit of thought to write and maintain. The predicate macro does not support calling methods on the items it is filtering, so I can't place the logic there.

Is there some way to put the closure and associated constants in a single location and reuse it for predicates and other logic. Or will I just need to cut and paste?


Solution

  • As you have already done in the #Predicate macro, you can just write methods that calls the Predicate-returning methods, and call evaluate(self) on the returned Predicate.

    For example, for reusing startEndAppliesDuring, you can write

    func startEndAppliesDuring(_ range: ClosedRange<Date>) -> Bool {
        let predicate = Item.startEndAppliesDuring(range)
        return try! predicate.evaluate(self)
    }
    

    Here is a peer macro that can generate such a method from the static, Predicate-returning method.

    // declaration
    @attached(peer, names: overloaded)
    public macro InstancePredicate() = #externalMacro(module: "...", type: "InstancePredicate")
    
    // implementation
    enum InstancePredicate: PeerMacro {
        static func expansion(
            of node: AttributeSyntax,
            providingPeersOf declaration: some DeclSyntaxProtocol,
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            guard var method = declaration.as(FunctionDeclSyntax.self),
                  let staticIndex = method.modifiers.firstIndex(where: { $0.name.text == "static" })
            else {
                throw "Must be applied on static method"
            }
            method.modifiers.remove(at: staticIndex)
            method.removeMacro("InstancePredicate")
            method.signature.returnClause?.type = "Bool"
            let argumentList = LabeledExprListSyntax {
                for parameter in method.signature.parameterClause.parameters {
                    if parameter.firstName.text != "_" {
                        LabeledExprSyntax(
                            label: parameter.firstName.text,
                            expression: DeclReferenceExprSyntax(baseName: parameter.secondName ?? parameter.firstName)
                        )
                    } else {
                        LabeledExprSyntax(expression: DeclReferenceExprSyntax(baseName: parameter.secondName!))
                    }
                }
            }
            let body = CodeBlockItemListSyntax {
                "let predicate = Self.\(raw: method.name)(\(argumentList))"
                "return try! predicate.evaluate(self)"
            }
            method.body?.statements = body
            return [DeclSyntax(method)]
        }
    }
    
    extension FunctionDeclSyntax {
        mutating func removeMacro(_ name: String) {
            attributes = attributes.filter { attribute in
                if case let .attribute(attributeSyntax) = attribute,
                   let type = attributeSyntax.attributeName.as(IdentifierTypeSyntax.self),
                   type.name.text == name {
                    return false
                } else {
                    return true
                }
            }
        }
    }
    
    // Here I conformed String to Error to easily emit diagnostics
    extension String: @retroactive Error {}
    
    // usage
    
    @InstancePredicate
    static func startEndAppliesDuring(_ range: ClosedRange<Date>) -> Predicate<Item> { ... }
    

    The macro assumes it is being attached to a static method returning Predicate<T> that is declared in a @Model class named T, and that the static method's arguments can all be trivially delegated (see how argumentList is created). That is, there is no tricky things like variadic parameters, autoclosures, inout parameters, etc.