Generic fluent query with protocol in Vapor 4

Viewed 145

I have several models that have a date field with the same name like this:

final class ModelA: Model {

static let schema = "model_a"

@Timestamp(key: "modification_date", on: .update, format: .unix)
var modificationDate: Date?

}

final class ModelB: Model {

static let schema = "model_b"

@Timestamp(key: "modification_date", on: .update, format: .unix)
var modificationDate: Date?

}

final class ModelC: Model {

static let schema = "model_c"

@Timestamp(key: "modification_date", on: .update, format: .unix)
var modificationDate: Date?

}

At some point in my code, I would like to get recently changed models. So I could write something like this:

func getRecentlyChangedModelAs(on db: Database) async throws -> [ModelA] {
   return try await ModelA.query(on: db)
                          .group(.and) { group in
                             group.filter(\.$modificationDate != nil)
                             group.filter(\.$modificationDate > Date.now.addingTimeInterval(TimeInterval(-3600)))
                          }
                          .all()
}

func getRecentlyChangedModelBs(on db: Database) async throws -> [ModelB] {
   return try await ModelB.query(on: db)
                          .group(.and) { group in
                             group.filter(\.$modificationDate != nil)
                             group.filter(\.$modificationDate > Date.now.addingTimeInterval(TimeInterval(-3600)))
                          }
                          .all()
}

func getRecentlyChangedModelCs(on db: Database) async throws -> [ModelC] {
   return try await ModelC.query(on: db)
                          .group(.and) { group in
                             group.filter(\.$modificationDate != nil)
                             group.filter(\.$modificationDate > Date.now.addingTimeInterval(TimeInterval(-3600)))
                          }
                          .all()
}

As you can see, there is a lot of code duplication. In order to avoid this, I tried to use a protocol and a generic query instead:

protocol ModifiableModel: Model, Content {
    var modificationDate: Date? { set get }
}

final class ModelA: ModifiableModel {

static let schema = "model_a"

@Timestamp(key: "modification_date", on: .update, format: .unix)
var modificationDate: Date?

}

etc...

func getRecentlyChangedModel<T: ModifiableModel>(on db: Database) async throws -> [T] {
   return try await T.query(on: db)
                     .group(.and) { group in
                        group.filter(\.$modificationDate != nil) // Errors: 'nil' is not compatible with expected argument type 'KeyPath<Right, RightField>', Generic parameter 'RightField' could not be inferred
                        group.filter(\.$modificationDate > Date.now.addingTimeInterval(TimeInterval(-3600))) // Errors: Cannot convert value of type 'Date' to expected argument type 'KeyPath<Right, RightField>', No exact matches in call to instance method 'filter'
                      }
                      .all()

Unfortunately, this causes errors in the filter of the query. I do not quite understand these errors but I found out that without the filter, everything compiles just fine. My best guess would be that the errors have something to do with the key paths I try to use within the filter.

Does anyone know what the problem is or how to fix it? Or is there another approach that I can use to avoid this kind of code duplication? Any help would be much appreciated!

2 Answers

I don't think this approach can be made to work. The clue is in the error message you get by putting T in the filter as in:

group.filter(\T.$modificationDate != nil)

Gives error:

Generic parameter 'Field' could not be inferred and Value of type 'T' has no member '$modificationDate'

The problem is due to the fact that filter is expecting a property defined with a field wrapper (in this case, @Timestamp). However, as you have probably discovered putting:

protocol ModifiableModel {
    @Timestamp(key: "modification_date", on: .update, format: .unix)
var modificationDate: Date?
}

results in an error message that you cannot define variables with wrappers in protocols. Since your approach depends on this being the case, I don't think it can be made to work.

EDIT:

I forgot to add that you should be able to simplify your code to just filtering on the value. There shouldn't be any need to test for non-nil first. Try:

func getRecentlyChangedModelAs(on db: Database) async throws -> [ModelA] {
   return try await ModelA.query(on: db)
                          .filter(\.$modificationDate > Date.now.addingTimeInterval(TimeInterval(-3600)))
                          .all()
}

If you are getting the wrong result, then check your migration to make sure there isn't something wrong here.

As it turns out, there is a solution, even two actually. They are not perfect in the sense that a small part of the code still needs to be duplicated, but it's still much better than any alternative I know.

This is the first way:

protocol ModifyableModel: Model {
    var modified: KeyPath<Self, TimestampProperty<Self, UnixTimestampFormat>> { get }
}

extension ModifyableModel {
    func getRecentlyModifiedModels(on db: Database) async throws -> [Self] {
        try await Self.query(on: db)
            .filter(Self().modified > Date.now.addingTimeInterval(TimeInterval(-3600))
            .all()
    }
}

extension ModelA: ModifyableModel {
    var modified: KeyPath<User, TimestampProperty<User, UnixTimestampFormat>> {
        return \.$modificationDate
    }
}

As you can see, the variable modified needs to be implemented separately for every Model that conforms to ModifyableModel. Just calling it modificationDate instead doesn't work. It's not perfect but it's a small price to pay. If anyone figures out a way to avoid this, please let me know.

Anyway, here is the other way of doing it. Personally, I think it's even a little bit more elegant without the KeyPath:

protocol ModifyableModel: Model {
    var modified: TimestampProperty<Self, UnixTimestampFormat> { get }
}

func getRecentlyModifiedModels<T: ModifyableModel>(on db: Database) async throws -> [T] {
    return try await T.query(on: db)
        .filter(\T.modified > Date.now.addingTimeInterval(TimeInterval(-3600))
        .all()
}

extension ModelA: ModifyableModel {
    var modified: TimestampProperty<User, UnixTimestampFormat> {
        return self.$modificationDate
    }
}

Thank you, Mahdi BM @ Discord, for your help!

Related