Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition · iOS 13 · Swift 5.2 - Vapor 4 Framework · Xcode 11.4

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Creating a Simple Web API

Section 1: 13 chapters
Show chapters Hide chapters

31. Advanced Fluent
Written by Tim Condon

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the previous sections of this book, you learned how to use Fluent to perform queries against a database. You also learned how to perform CRUD operations on models. In this chapter, you’ll learn about some of Fluent’s more advanced features. You’ll see how to save models with enums and use Fluent’s soft delete and timestamp features. You’ll also learn how to use raw SQL and joins, as well as seeing how to “eager load” relationships.

Getting started

The starter project for this chapter is based on the TIL application from the end of chapter 21. You can either use your code from that project or use the starter project included in the book materials for this chapter. This project relies on a PostgreSQL database running locally.

Clearing the existing database

If you’ve followed along from the previous chapters, you need to delete the existing database. This chapter contains model changes which require either reverting your database or deleting it. In Terminal, type:

docker rm -f postgres

Creating a new database

Create a new database in Docker for the TIL application to use. In Terminal, type:

docker run --name postgres \
  -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

Soft delete

In Chapter 7, “CRUD Database Operations”, you learned how to delete models from the database. However, while you may want models to appear deleted to users, you might not want to actually delete them. You could also have legal or company requirements which enforce retention of data. Fluent provides soft delete functionality to allow you to do this. Open the TIL app in Xcode and go to User.swift. Look for:

var acronyms: [Acronym]
@Timestamp(key: "deleted_at", on: .delete)
var deletedAt: Date?
.field("deleted_at", .datetime)
func deleteHandler(_ req: Request) 
  -> EventLoopFuture<HTTPStatus> {
    User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound)).flatMap { user in
        user.delete(on: req.db).transform(to: .noContent)
    }
}
tokenAuthGroup.delete(":userID", use: deleteHandler)

Restoring Users

Even though the application now allows you to soft delete users, you may want to restore them at a future date. First, add the following below import Vapor at the top of UsersController.swift:

import Fluent
func restoreHandler(_ req: Request) 
  throws -> EventLoopFuture<HTTPStatus> {
    // 1
    let userID = 
      try req.parameters.require("userID", as: UUID.self)
    // 2
    return User.query(on: req.db)
      .withDeleted()
      .filter(\.$id == userID)
      .first()
      .unwrap(or: Abort(.notFound))
      .flatMap { user in
        // 3
        user.restore(on: req.db).transform(to: .ok)
    }
}
tokenAuthGroup.post(":userID", "restore", use: restoreHandler)

docker exec -it postgres psql -U vapor_username vapor_database
select id from "users" where username = ’<your username>’;
\q

Force delete

Now that you can soft delete and restore users, you may want to add the ability to properly delete a user. You use force delete for this. Back in Xcode, still in UsersController.swift, create a new route to do this. Add the following below restoreHandler(_:):

func forceDeleteHandler(_ req: Request) 
  -> EventLoopFuture<HTTPStatus> {
    User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound))
      .flatMap { user in
        user.delete(force: true, on: req.db)
          .transform(to: .noContent)
  }
}
tokenAuthGroup.delete(
  ":userID", 
  "force", 
  use: forceDeleteHandler)

Timestamps

Fluent has built-in functionality for timestamps for a model’s creation time and update time. In fact, you used one above to implement soft-delete functionality. If you configure these, Fluent automatically sets and updates the times. To enable this, open Acronym.swift in Xcode. Below var categories: [Category] add two new properties for the dates:

@Timestamp(key: "created_at", on: .create)
var createdAt: Date?

@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
.field("created_at", .datetime)
.field("updated_at", .datetime)
func getMostRecentAcronyms(_ req: Request) 
  -> EventLoopFuture<[Acronym]> {
    Acronym.query(on: req.db)
      .sort(\.$updatedAt, .descending)
      .all()
}
acronymsRoutes.get("mostRecent", use: getMostRecentAcronyms)
docker rm -f postgres
docker run --name postgres \
  -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

Enums

A common requirement for database columns is to restrict the values to a pre-defined set. Both FluentPostgreSQL and FluentMySQL support enums for this. To demonstrate this, you’ll add a type to the user to define basic user access levels. In Xcode, create a new file called UserType.swift in Sources/App/Models. Open the new file and add the following:

import Foundation

// 1
enum UserType: String, Codable {
  // 2
  case admin
  case standard
  case restricted
}
@Enum(key: "userType")
var userType: UserType
init(
  id: UUID? = nil,
  name: String,
  username: String,
  password: String,
  userType: UserType = .standard
) {
  self.name = name
  self.username = username
  self.password = password
  self.userType = userType
}
let user = User(
  name: "Admin", 
  username: "admin", 
  password: passwordHash, 
  userType: .admin)
// 1
database.enum("userType")
  // 2
  .case("admin")
  .case("standard")
  .case("restricted")
  // 3
  .create()
  .flatMap { userType in
    database.schema("users")
      .id()
      .field("name", .string, .required)
      .field("username", .string, .required)
      .field("password", .string, .required)
      .field("deleted_at", .datetime)
      // 4
      .field("userType", userType, .required)
      .unique(on: "username")
      .create()
}
func deleteHandler(_ req: Request) 
  throws -> EventLoopFuture<HTTPStatus> {
// 1
let requestUser = try req.auth.require(User.self)
// 2
guard requestUser.userType == .admin else {
  throw Abort(.forbidden)
}
// 3
return User.find(req.parameters.get("userID"), on: req.db)
  .unwrap(or: Abort(.notFound))
  .flatMap { user in
    user.delete(on: req.db)
      .transform(to: .noContent)
}

Lifecycle hooks

Fluent allows you to hook into various aspects of a model’s lifecycle using model middleware. These work in a similar way to other middleware and allow you to execute code before and after different events. For more information on middleware, see Chapter 29, “Middleware”. Fluent allows you to add middleware for the following events:

import Fluent
import Vapor

// 1
struct UserMiddleware: ModelMiddleware {
  // 2
  func create(
    model: User, 
    on db: Database, 
    next: AnyModelResponder) -> EventLoopFuture<Void> {
    // 3
    User.query(on: db)
      .filter(\.$username == model.username)
      .count()
      .flatMap { count in
        // 4
        guard count == 0 else {
          let error = 
            Abort(
              .badRequest, 
              reason: "Username already exists")
          return db.eventLoop.future(error: error)
        }
        // 5
        return next.create(model, on: db).map {
          // 6
          let errorMessage: Logger.Message = 
            "Created user with username \(model.username)"
          db.logger.debug(errorMessage)
        }
    }
  }
}
app.databases.middleware.use(UserMiddleware(), on: .psql)

Eager loading and nested models

If you follow a strict REST API, you should retrieve a model’s children in a separate request. However, this isn’t alway ideal, and you may want the ability to send a single request to get all models with all their children. For example, in the TIL application, you may want a route that returns all categories with all their acronyms. You may even want to return all categories with all their acronyms with all their users. This is commonly referred to as the N+1 problem and Fluent makes this easy with eager loading. Open CategoriesController.swift and add the following at the bottom of the file:

struct AcronymWithUser: Content {
  let id: UUID?
  let short: String
  let long: String
  let user: User.Public
}

struct CategoryWithAcronyms: Content {
  let id: UUID?
  let name: String
  let acronyms: [AcronymWithUser]
}
func getAllCategoriesWithAcronymsAndUsers(_ req: Request) 
  -> EventLoopFuture<[CategoryWithAcronyms]> {
    // 1
    Category.query(on: req.db)
      // 2
      .with(\.$acronyms) { acronyms in
        // 3
        acronyms.with(\.$user)
      // 4
      }.all().map { categories in
        // 5
        categories.map { category in
          // 6
          let categoryAcronyms = category.acronyms.map {
            AcronymWithUser(
              id: $0.id, 
              short: $0.short, 
              long: $0.long, 
              user: $0.user.convertToPublic())
          }
          // 7
          return CategoryWithAcronyms(
            id: category.id, 
            name: category.name, 
            acronyms: categoryAcronyms)
        }
      }
}
categoriesRoute.get(
  "acronyms", 
  use: getAllCategoriesWithAcronymsAndUsers)

Joins

Sometimes, you want to query other tables when retrieving information. For example, you might want to get the user who created the most recent acronym. You could do this with eager loading and Swift. You’d do this by getting all the users and eager load their acronyms. You can then sort the acronyms by their created date to get the most recent and return its user. However, this means loading all users and their acronyms into memory, even if you don’t want them, which is inefficient. Joins allow you to combine columns from one table with columns from another table by specifying the common values. For example, you can combine the acronyms table with the users table using the users’ IDs. You can then sort, or even filter, across the different tables.

func getUserWithMostRecentAcronym(_ req: Request) 
  -> EventLoopFuture<User.Public> {
    // 1
    User.query(on: req.db)
      // 2
      .join(Acronym.self, on: \Acronym.$user.$id == \User.$id)
      // 3
      .sort(Acronym.self, \Acronym.$createdAt, .descending)
      // 4
      .first()
      .unwrap(or: Abort(.internalServerError))
      .convertToPublic()
}
usersRoute.get(
  "mostRecentAcronym", 
  use: getUserWithMostRecentAcronym)

Raw SQL

Whilst Fluent provides tools to allow you to build lots of different behaviors, there are some advanced features it doesn’t offer. Fluent doesn’t support querying different schemas or aggregate functions. In a complex application, you may find that there are scenarios where Fluent doesn’t provide the functionality you need. In these cases, you can use raw SQL queries to interact with the database directly. This allows you to perform any type of query the database supports.

import SQLKit
func getAllAcronymsRaw(_ req: Request) 
  throws -> EventLoopFuture<[Acronym]> {
    // 1
    guard let sql = req.db as? SQLDatabase else {
      throw Abort(.internalServerError)
    }
    // 2
    return sql.raw("SELECT * FROM acronyms")
      // 3
      .all(decoding: Acronym.self)
}
acronymsRoutes.get("raw", use: getAllAcronymsRaw)

Where to go from here?

In this chapter, you learned how to use some of the advanced features Fluent provides to perform complex queries. You also saw how to send raw SQL queries if Fluent can’t do what you need.

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now