Home iOS & Swift Books Server-Side Swift with Vapor

27
Database/API Versioning & Migration Written by Tim Condon

In the first three sections of the book, whenever you made a change to your model, you had to delete your database and start over. That’s no problem when you don’t have any data. Once you have data, or move your project to the production stage, you can no longer delete your database. What you want to do instead is modify your database, which in Vapor, is done using migrations.

In this chapter, you’ll make two modifications to the TILApp using migrations. First, you’ll add a new field to User to contain a Twitter handle. Second, you’ll ensure that categories are unique. Finally, you’re going to modify the app so it creates the admin user only when your app runs in development or testing mode.

Note: The starter project for this chapter is based on the TIL application from the end of chapter 21. The starter project contains extra code, so you should use the starter project from this chapter. This project relies on a PostgreSQL database running locally.

How migrations works

When Fluent runs for the first time, it creates a special table in the database. Fluent uses this table to track all migrations it has run and it runs migrations in the order you add them. When your application starts, Fluent checks the list of migrations to run. If it has run a migration, it will move on to the next one. If it hasn’t run the migration before, Fluent executes it.

Fluent will never run migrations more than once. Doing so would cause conflicts with the existing data in the database. For example, imagine you have a migration that creates a table for your users. The first time Fluent runs the migration, it creates the table. It it tries to run it again a table with the name would already exist, causing an error.

It’s important to remember this. If you change an existing migration, Fluent will not execute it. You need to reset your database as you did in the earlier chapters.

Modifying tables

Modifying an existing database is always a risky business. You already have data you don’t want to lose, so deleting the whole database is not a viable solution. At the same time, you can’t simply add or remove a property in an existing table since all the data is entangled in one big web of connections and relations.

Writing migrations

A Migration is generally written as a struct when it’s used to update an existing model. This struct must, of course, conform to Migration. Migration requires you to provide two things:

func prepare(on database: Database) -> EventLoopFuture<Void>

func revert(on database: Database) -> EventLoopFuture<Void>

Prepare method

Migrations require a database connection to work correctly as they must be able to query the MigrationLog model. If the MigrationLog is not accessible, the migration will fail and, in the worst case, break your application. prepare(on:) contains the migration’s changes to the database. It’s usually one of two options:

func prepare(on database: Database) -> EventLoopFuture<Void> {
  // 1
  database.schema("testUsers")
    // 2
    .id()
    .field("name", .string, .required)
    // 3
    .create()
}

Revert method

revert(on:) is the opposite of prepare(on:). Its job is to undo whatever prepare(on:) did. If you use create() in prepare(on:), you use delete() in revert(on:). If you use update() to add a field, you also use it in revert(on:) to remove the field with deleteField(_:).

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

FieldKeys

In Vapor 3, Fluent inferred most of the table information for you. This included the column types and the names of the columns. This worked well for small apps such as the TIL app. However, as projects grow, they make more and more changes. Removing fields and changing names of columns was difficult because the columns no longer matched the model. Fluent 4 makes migrations a lot more flexible by requiring you to provide the names of fields and schemas.

extension Acronym {
  // 1
  enum v20210114 {
    // 2
    static let schemaName = "acronyms"
    // 3
    static let id = FieldKey(stringLiteral: "id")
    static let short = FieldKey(stringLiteral: "short")
    static let long = FieldKey(stringLiteral: "long")
    static let userID = FieldKey(stringLiteral: "userID")
  }
}
func prepare(on database: Database) -> EventLoopFuture<Void> {
  database.schema(Acronym.v20210114.schemaName)
    .id()
    .field(Acronym.v20210114.short, .string, .required)
    .field(Acronym.v20210114.long, .string, .required)
    .field(
      Acronym.v20210114.userID, 
      .uuid, 
      .required, 
      .references(User.v20210113.schemaName, User.v20210113.id))
    .create()
}
  
func revert(on database: Database) -> EventLoopFuture<Void> {
  database.schema(Acronym.v20210114.schemaName).delete()
}
static let schema = "acronyms"
static let schema = Acronym.v20210114.schemaName
@Field(key: Acronym.v20210114.short)
var short: String

@Field(key: Acronym.v20210114.long)
var long: String

@Parent(key: Acronym.v20210114.userID)
var user: User
.field(
  AcronymCategoryPivot.v20210113.acronymID,
  .uuid,
  .required,
  .references("acronyms", "id", onDelete: .cascade))
.field(
  AcronymCategoryPivot.v20210113.acronymID, 
  .uuid, 
  .required, 
  .references(
    Acronym.v20210114.schemaName, 
    Acronym.v20210114.id, 
    onDelete: .cascade))

Adding users’ Twitter handles

To demonstrate the migration process for an existing database, you’re going to add support for collecting and storing users’ Twitter handles. In Xcode, create a new file called 21-01-14-AddTwitterToUser.swift in Sources/App/Migrations. This new file will hold the AddTwitterToUser migration.

enum v20210114 {
  static let twitterURL = FieldKey(stringLiteral: "twitterURL")
}
@OptionalField(key: User.v20210114.twitterURL)
var twitterURL: String?
init(
  id: UUID? = nil,
  name: String,
  username: String,
  password: String,
  twitterURL: String? = nil
) {
  self.name = name
  self.username = username
  self.password = password
  self.twitterURL = twitterURL
}

Creating the migration

Open 21-01-14-AddTwitterToUser.swift and add the following to create a migration that adds the new twitterURL field to the model:

import Fluent

// 1
struct AddTwitterURLToUser: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema(User.v20210113.schemaName)
      // 4
      .field(User.v20210114.twitterURL, .string)
      // 5
      .update()
  }

  // 6
  func revert(on database: Database) -> EventLoopFuture<Void> {
    // 7
    database.schema(User.v20210113.schemaName)
      // 8
      .deleteField(User.v20210114.twitterURL)
      // 9
      .update()
  }
}
app.migrations.add(AddTwitterURLToUser())
docker exec -it postgres psql -U vapor_username vapor_database
\d "users"
\q

Versioning the API

You’ve changed the model to include the user’s Twitter handle, but you haven’t altered the existing API. While you could simply update the API to include the Twitter handle, this might break existing consumers of your API. Instead, you can create a new API version to return users with their Twitter handles.

final class PublicV2: Content {
  var id: UUID?
  var name: String
  var username: String
  var twitterURL: String?

  init(id: UUID?, 
       name: String, 
       username: String, 
       twitterURL: String? = nil) {
    self.id = id
    self.name = name
    self.username = username
    self.twitterURL = twitterURL
  }
}
func convertToPublicV2() -> User.PublicV2 {
  return User.PublicV2(
  	id: id, 
  	name: name, 
  	username: username, 
  	twitterURL: twitterURL)
}
func convertToPublicV2() -> EventLoopFuture<User.PublicV2> {
  return self.map { user in
    return user.convertToPublicV2()
  }
}
func convertToPublicV2() -> [User.PublicV2] {
  return self.map { $0.convertToPublicV2() }
}
func convertToPublicV2() -> EventLoopFuture<[User.PublicV2]> {
  return self.map { $0.convertToPublicV2() }
}
// 1
func getV2Handler(_ req: Request) 
    -> EventLoopFuture<User.PublicV2> {
  // 2
  User.find(req.parameters.get("userID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .convertToPublicV2()
}
// API Version 2 Routes
// 1
let usersV2Route = routes.grouped("api", "v2", "users")
// 2
usersV2Route.get(":userID", use: getV2Handler)

Updating the web site

Your app now has all it needs to store a user’s Twitter handle and the API is complete. You need to update the web site to allow a new user to provide a Twitter address during the registration process.

<div class="form-group">
  <label for="twitterURL">Twitter handle</label>
  <input type="text" name="twitterURL" class="form-control"
   id="twitterURL"/>
</div>
<h2>#(user.username)
  #if(user.twitterURL):
  - @#(user.twitterURL)
  #endif
</h2>
let twitterURL: String?
let user = User(
  name: data.name,
  username: data.username,
  password: password)
var twitterURL: String?
if let twitter = data.twitterURL,
   !twitter.isEmpty {
    twitterURL = twitter
}
let user = User(
  name: data.name,
  username: data.username,
  password: password,
  twitterURL: twitterURL)

Making categories unique

Just as you’ve required usernames to be unique, you really want category names to be unique as well. Everything you’ve done so far to implement categories has made it impossible to create duplicates, but you’d like that enforced in the database as well. It’s time to create a Migration that guarantees duplicate category names can’t be inserted in the database.

import Fluent

// 1
struct MakeCategoriesUnique: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema(Category.v20210113.schemaName)
      // 4
      .unique(on: Category.v20210113.name)
      // 5
      .update()
  }

  // 6
  func revert(on database: Database) -> EventLoopFuture<Void> {
    // 7
    database.schema(Category.v20210113.schemaName)
      // 8
      .deleteUnique(on: Category.v20210113.name)
      // 9
      .update()
  }
}
app.migrations.add(MakeCategoriesUnique())

Seeding based on environment

In Chapter 18, “API Authentication, Part 1,” you seeded an admin user in your database. As mentioned there, you should never use “password” as your admin password. But, it’s easier when you’re still developing and just need a dummy account for testing locally. One way to ensure you don’t add this user in production is to detect your environment before adding the migration. In configure.swift replace:

app.migrations.add(CreateAdminUser())
switch app.environment {
case .development, .testing:
  app.migrations.add(CreateAdminUser())
default:
  break
}

Where to go from here?

In this chapter, you learned how to modify your database, after your app enters production, using migrations. You saw how to add an extra property — twitterUrl — to User, how to revert this update and how to enforce uniqueness of category names. Finally, you saw how to switch on your environment in configure.swift, allowing you to exclude migrations from the production environment.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.