Tuesday, May 26, 2020

Creating a Database Enum in Vapor 4

Vapor has essentially become the web framework for the Swift programming language so I’ve been doing quite a bit of experimenting with it. Unfortunately, the documentation for the latest version is still rather incomplete as of this writing. As such, I thought I would add to the general body of knowledge about Vapor available on the web.

For a project I’ve started working on, I wanted to add an enumerated type (more commonly known simply as an ENUM) to my PostgreSQL database, use that ENUM as the type on a column on one of my tables, and have a default value. The first thing, then, was to define the ENUM itself in Swift:

Sources/App/Models/Account.swift

enum BillingStatus: String, Codable {
    case current
    case pastDue
}

Then I could define a model that uses the ENUM:

Sources/App/Models/Account.swift

final class Account: Model {
    static let schema = "accounts"

    @ID(key: .id)
    var id: UUID?

    @Enum(key: "billing_status")
    var billingStatus: BillingStatus

    init() {}

    init(
        id: IDValue? = nil,
        billingStatus: BillingStatus = .current
    ) {
        self.id = id
        self.billingStatus = status
    }
}

Lastly, I needed to define the migration code. This is a little more tricky that you might at first suppose for two reasons: (1) the ENUM type must be created before it can be used as a column type on the table, and (2) Vapor 4’s migrations are asynchronous, so extra steps are needed to ensure a consistent order of operations. To handle this situation, I used flatMap to provide a callback block that will execute as soon as the ENUM definition block has finished. The inverse must be also done in the revert method.

Sources/App/Migrations/CreateAccount.swift

import Fluent

struct CreateAccount: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        return database.enum("billing_status")
            .case("current")
            .case("past_due")
            .create()
            .flatMap { billing_status in
                return database.schema("accounts")
                    .id()
                    .field(
                        "billing_status",
                        billing_status,
                        .required,
                        .custom("DEFAULT 'current'")
                    )
                    .create()
            }
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("accounts").delete().flatMap {
            return database.enum("billing_status").delete()
        }
    }
}

I used Vapor’s .custom method to specify that current is the default, mirroring the default used in the model code’s init method. You may well feel that specifying a default in both the database and the application layers is redundant but I prefer to keep the two in sync where feasible.

All code tested with Swift 5.2, Vapor 4.5, and Fluent 4.0.0-rc2.2 on both macOS 10.15 and Ubuntu Linux 18.04.