[Idea] @DeletedField in Fluent

I'd like to put forward an idea I had with the goal of reducing the amount of boilerplate required for schema migrations in Fluent. It came to me when reading the following:

Introduction

A very common pattern I utilize in my code in order to avoid stringly typed errors is the following:

// Model
final class User: Model {
    static let schema = "users"

    @ID
    var id: UUID?

    // Previously:
    // @Field(key: FieldKeys.name)
    // var name: String

    @Field(key: FieldKeys.firstName)
    var firstName: String

    @Field(key: FieldKeys.lastName)
    var lastName: String
}

extension User {
    enum FieldKeys {
        static let id: FieldKey = .id
        static let name: FieldKey = "name"

        static let firstName: FieldKey = "first_name"
        static let lastName: FieldKey = "last_name"
    }
}
// Migration
struct UserNameMigration: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.schema)
            .deleteField(User.FieldKeys.name)
            .field(User.FieldKeys.firstName, .string)
            .field(User.FieldKeys.lastName, .string)
            .update()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.schema).delete()
    }
}

AFAIK, the only reason SchemaBuilder uses strings over generics + key paths to represent fields is because fields may be deleted at any point, causing future compilation errors.

Proposed Solution

I'd like to propose the introduction of a @DeletedField property wrapper in order to enable strongly typed SchemaBuilder migrations, roughly as follows:

// Model
final class User: Model {
    static let schema = "users"

    @ID
    var id: UUID?

    @DeletedField(key: "name")
    var name: String

    @Field(key: "firstName")
    var firstName: String

    @Field(key: "lastName")
    var lastName: String
}
// Migration
struct UserNameMigration: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.self) // is SchemaBuilder<User>
            .deleteField(\.$name)
            .field(\.$firstName, .string)
            .field(\.$lastName, .string)
            .update()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.self).delete()
    }
}

@DeletedField would look just like @Field in its signature, but it'd be a stripped down version of it. It'd have none of the querying functionality that its counterpart has, and it'd only serve the SchemaBuilder to retrieve the raw field key.

Of course, other @Deleted... variants would be needed too, e.g. @DeletedOptionalField, @DeletedParent, etc... but that's just the cost of implementation.

With this approach, I think the pros in maintainability, readability and safety would outweigh the slight con of needing to keep the deleted properties around in the model. In a real world scenario, these properties could just be grouped away at the developer's discretion. Furthermore, this untidiness also occurs in the original approach of keeping around old FieldKeys, so it's not exactly a new con.

I'll let @graskind chime in with how this would fit into what is planned for Fluent 5 but I'll offer some thoughts:

I like the idea of improving how we handle models. However I don't think this is something the core team would accept as is. The main issue is the old properties still being present on the model. Whilst they may not be encoded or decoded to/from the DB, they will still be present on the model which means they will be accessible by a user and this opens up a can of worms for user error. Deleting the property (as happens currently) means the compiler will immediately flag places where your code needs to be updated. I would argue that I'd rather have some old FieldKeys existing than old properties you can still address.

Secondly you're partially correct in the statement of the decoupling being for deleted fields. Migrations and models were decoupled as it caused a lot of headaches in Vapor 3. Not only does deleting fields become significantly easier, but running migrations well before (and separately) code deployment is possible because they are no longer linked. The other missing piece if this idea is field renaming (I'll leave out table renaming because that is a whole other can of worms!) - how would you propose a field rename would work? Would we have two properties, one for the old name and one for the new one and use the proposed @DeletedField or a new @RenamedField? I can see this causing confusion, especially when working on large projects with multiple devs.

Hopefully those points provide a start for a discussion and it doesn't come across as too negative! It's great to see pitches from the community in how we could improve things!

PS - we would love to be able to automate a lot of migrations a la Rails but we need Swift's reflection capabilities to improve significantly until we can get that to work.

PPS - when working on large projects I find date spacing migrations helpful when looking back to see when things were added and removed etc. E.g.:

extension User {
    enum v20210915 {
        static let id: FieldKey = .id
        static let name: FieldKey = "name"
    }

    enum v20211015 {
        static let firstName: FieldKey = "first_name"
        static let lastName: FieldKey = "last_name"
    }
}

Makes it clear in which order the migrations were run etc

1 Like

Thank you so much for your detailed response Tim. I actually went ahead and attempted an implementation on a branch (diff).

I had this same concern coming in, and then I realized that it can be mitigated by making all deleted property be of type Never, which will have the compiler force the user to reason about uses of the property in the codebase. This actually makes sense beyond just compiler enforcement: removing the original property types unanimously lets the developer know that a deleted property is effectively unreadable/unwritable, and can only be treated as a key container.

final class Foo: Model {
    static let schema = "foos"

    @DeletedID
    var id

    @DeletedField(key: "aField")
    var aField // implicit type 'Never'

    @DeletedOptionalField(key: "anOptionalField")
    var anOptionalField
}

// Adding a field
db.schema(Foo.self).field(\.$aField, .string, .required)

// Deleting a field
db.schema(Foo.self).deleteField(\.$aField)

This is true, and since I'm not familiar with this use case I don't have much to say about this. It might be a big deal for bigger projects. Definitely up for discussion.

I can however speak in terms of how these changes affect existing migrations. TypedSchemeBuilder is actually implemented as a subclass of SchemeBuilder, which means this strongly typed layer is purely additive and strictly opt-in — it even allows mix-and-matching of key paths and string keys. So the implementation can (and IMO should) indeed be purely additive.

I don't find myself renaming fields very often so I didn't really give any thought to this. Let me come back to you on this though.

Not at all! This is what we're here for :smile:

I recognize this from your book :ok_hand:

It's definitely a good approach to the stringly typed model. Only downside is the verbosity in field annotations and schema migrations — which is what brought me to this idea in the first place :sweat_smile:

1 Like