Home iOS & Swift Books Server-Side Swift with Vapor

10
Sibling Relationships Written by Tim Condon

In Chapter 9, “Parent-Child Relationships”, you learned how to use Fluent to build parent-child relationships between models. This chapter shows you how to implement the other type of relationship: sibling relationships. You’ll learn how to model them in Vapor and how to use them in routes.

Note: This chapter requires that you have set up and configured PostgreSQL. Follow the steps in Chapter 6, “Configuring a Database”, to set up PostgreSQL in Docker and configure the Vapor application.

Sibling relationships

Sibling relationships describe a relationship that links two models to each other. They are also known as many-to-many relationships. Unlike parent-child relationships, there are no constraints between models in a sibling relationship.

For instance, if you model the relationship between pets and toys, a pet can have one or more toys and a toy can be used by one or more pets. In the TIL application, you’ll be able to categorize acronyms. An acronym can be part of one or more categories and a category can contain one or more acronyms.

Creating a category

To implement categories, you’ll need to create a model, a migration, a controller and a pivot. Begin by creating the model.

Category model

In Xcode, create a new file Category.swift in Sources/App/Models. Open the file and insert a basic model for a category:

import Fluent
import Vapor

final class Category: Model, Content {
  static let schema = "categories"
  
  @ID
  var id: UUID?
  
  @Field(key: "name")
  var name: String
  
  init() {}
  
  init(id: UUID? = nil, name: String) {
    self.id = id
    self.name = name
  }
}

The model contains a String property to hold the category’s name. The model also contains an optional id property that stores the ID of the model when it’s set. You annotate both the properties with their respective property wrappers.

Next, create a new file CreateCategory.swift in Sources/App/Migrations. Insert the following into the new file:

import Fluent

struct CreateCategory: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema("categories")
      .id()
      .field("name", .string, .required)
      .create()
  }
  
  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema("categories").delete()
  }
}

This should be clear to you now! It creates the table using the same value as schema defined in the model with the necessary properties. The migration deletes the table in revert(on:).

Finally, open configure.swift and add CreateCategory to the migration list, after app.migrations.add(CreateAcronym()):

app.migrations.add(CreateCategory())

This adds the new migration to the application’s migrations so that Fluent creates the table in the database at the next application start.

Category controller

Now it’s time to create the controller. In Sources/App/Controllers, create a new file called CategoriesController.swift. Open the file and add code for a new controller to create and retrieve categories:

import Vapor

// 1
struct CategoriesController: RouteCollection {
  // 2
  func boot(routes: RoutesBuilder) throws {
    // 3
    let categoriesRoute = routes.grouped("api", "categories")
    // 4
    categoriesRoute.post(use: createHandler)
    categoriesRoute.get(use: getAllHandler)
    categoriesRoute.get(":categoryID", use: getHandler)
  }
  
  // 5
  func createHandler(_ req: Request) 
    throws -> EventLoopFuture<Category> {
    // 6
    let category = try req.content.decode(Category.self)
    return category.save(on: req.db).map { category }
  }
  
  // 7
  func getAllHandler(_ req: Request) 
    -> EventLoopFuture<[Category]> {
    // 8
    Category.query(on: req.db).all()
  }
  
  // 9
  func getHandler(_ req: Request) 
    -> EventLoopFuture<Category> {
    // 10
    Category.find(req.parameters.get("categoryID"), on: req.db)
      .unwrap(or: Abort(.notFound))
  }
}

Here’s what the controller does:

  1. Define a new CategoriesController type that conforms to RouteCollection.
  2. Implement boot(routes:) as required by RouteCollection. This is where you register route handlers.
  3. Create a new route group for the path /api/categories.
  4. Register the route handlers to their routes.
  5. Define createHandler(_:) that creates a category.
  6. Decode the category from the request and save it.
  7. Define getAllHandler(_:) that returns all the categories.
  8. Perform a Fluent query to retrieve all the categories from the database.
  9. Define getHandler(_:) that returns a single category.
  10. Get the ID from the request and use it to find the category.

Finally, open routes.swift and register the controller by adding the following to the end of routes(_:):

let categoriesController = CategoriesController()
try app.register(collection: categoriesController)

As in previous chapters, this instantiates a controller and registers it with the app to enable its routes.

Build and run the application, then create a new request in RESTed. Configure the request as follows:

Add a single parameter with name and value:

  • name: Teenager

Send the request and you’ll see the saved category in the response:

Creating a pivot

In Chapter 9, “Parent-Child Relationships”, you added a reference to the user in the acronym to create the relationship between an acronym and a user. However, you can’t model a sibling relationship like this as it would be too inefficient to query. If you had an array of acronyms inside a category, to search for all categories of an acronym you’d have to inspect every category. If you had an array of categories inside an acronym, to search for all acronyms in a category you’d have to inspect every acronym. You need a separate model to hold on to this relationship. In Fluent, this is a pivot.

A pivot is another model type in Fluent that contains the relationship. In Xcode, create this new model file called AcronymCategoryPivot.swift in Sources/App/Models. Open AcronymCategoryPivot.swift and add the following to create the pivot:

import Fluent
import Foundation

// 1
final class AcronymCategoryPivot: Model {
  static let schema = "acronym-category-pivot"
  
  // 2
  @ID
  var id: UUID?
  
  // 3
  @Parent(key: "acronymID")
  var acronym: Acronym
  
  @Parent(key: "categoryID")
  var category: Category
  
  // 4
  init() {}
  
  // 5
  init(
    id: UUID? = nil, 
    acronym: Acronym,
    category: Category
  ) throws {
    self.id = id
    self.$acronym.id = try acronym.requireID()
    self.$category.id = try category.requireID()
  }
}

Here’s what this model does:

  1. Define a new object AcronymCategoryPivot that conforms to Model.
  2. Define an id for the model. Note this is a UUID type so you must import the Foundation module.
  3. Define two properties to link to the Acronym and Category. You annotate the properties with the @Parent property wrapper. A pivot record can point to only one Acronym and one Category, but each of those types can point to multiple pivots.
  4. Implement the empty initializer, as required by Model.
  5. Implement an initializer that takes the two models as arguments. This uses requireID() to ensure the models have an ID set.

Next create the migration for the pivot. Create a new file, CreateAcronymCategoryPivot.swift, in Sources/App/Migrations. Open the new file and insert the following:

import Fluent

// 1
struct CreateAcronymCategoryPivot: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema("acronym-category-pivot")
      // 4
      .id()
      // 5
      .field("acronymID", .uuid, .required,
        .references("acronyms", "id", onDelete: .cascade))
      .field("categoryID", .uuid, .required,
        .references("categories", "id", onDelete: .cascade))
      // 6
      .create()
  }
  
  // 7
  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema("acronym-category-pivot").delete()
  }
}

Here’s what the new migration does:

  1. Define a new type, CreateAcronymCategoryPivot that conforms to Migration.

  2. Implement prepare(on:) as required by Migration.

  3. Select the table using the schema name defined for AcronymCategoryPivot.

  4. Create the ID column.

  5. Create the two columns for the two properties. These use the key provided to the property wrapper, set the type to UUID, and mark the column as required. They also set a reference to the respective model to create a foreign key constraint. As in Chapter 9, “Parent-Child Relationships,” it’s good practice to use foreign key constraints with sibling relationships. The current AcronymCategoryPivot does not check the IDs for the acronyms and categories. Without the constraint you can delete acronyms and categories that are still linked by the pivot and the relationship will remain, without flagging an error. The migration also sets a cascade schema reference action when you delete the model. This causes the database to remove the relationship automatically instead of throwing an error.

  6. Call create() to create the table in the database.

  7. Implement revert(on:) as required by Migration. This deletes the table in the database.

Finally, open configure.swift and add CreateAcronymCategoryPivot to the migration list, after app.migrations.add(CreateCategory()):

app.migrations.add(CreateAcronymCategoryPivot())

This adds the new pivot model to the application’s migrations so that Fluent prepares the table in the database at the next application start.

To actually create a relationship between two models, you need to use the pivot. Fluent provides convenience functions for creating and removing relationships. First, open Acronym.swift and add a new property to the model below var user: User:

@Siblings(
  through: AcronymCategoryPivot.self,
  from: \.$acronym,
  to: \.$category)
var categories: [Category]

This adds a new property to allow you to query the sibling relationship. You annotate the new property with the @Siblings property wrapper. @Siblings take three parameters:

  • the pivot’s model type
  • the key path from the pivot which references the root model. In this case you use the acronym property on AcronymCategoryPivot.
  • the key path from the pivot which references the related model. In this case you use the category property on AcronymCategoryPivot.

Like @Parent, @Siblings allows you to specify related models as a property without needing them to initialize an instance. The property wrapper also tells Fluent how to map the siblings when performing queries in the database.

While @Parent uses the parent ID column in the database, @Siblings has to join between the two different models and the pivot in the database. Thankfully, Fluent abstracts this away for you and makes it easy!

Open AcronymsController.swift and add the following route handler below getUserHandler(_:) to set up the relationship between an acronym and a category:

// 1
func addCategoriesHandler(_ req: Request) 
  -> EventLoopFuture<HTTPStatus> {
  // 2
  let acronymQuery = 
    Acronym.find(req.parameters.get("acronymID"), on: req.db)
      .unwrap(or: Abort(.notFound))
  let categoryQuery = 
    Category.find(req.parameters.get("categoryID"), on: req.db)
      .unwrap(or: Abort(.notFound))
  // 3
  return acronymQuery.and(categoryQuery)
    .flatMap { acronym, category in
      acronym
        .$categories
        // 4
        .attach(category, on: req.db)
        .transform(to: .created)
    }
}

Here’s what the route handler does:

  1. Define a new route handler, addCategoriesHandler(_:), that returns EventLoopFuture<HTTPStatus>.
  2. Define two properties to query the database and get the acronym and category from the IDs provided to the request. Each property is an EventLoopFuture.
  3. Use and(_:) to wait for both futures to return.
  4. Use attach(_:on:) to set up the relationship between acronym and category. This creates a pivot model and saves it in the database. Transform the result into a 201 Created response. Like many of Fluent’s operations, you call attach(_:on:) on the property wrappers projected value, rather than the property itself.

Register this route handler at the bottom of boot(routes:):

acronymsRoutes.post(
  ":acronymID", 
  "categories", 
  ":categoryID", 
  use: addCategoriesHandler)

This routes an HTTP POST request to /api/acronyms/<ACRONYM_ID>/categories/<CATEGORY_ID> to addCategoriesHandler(_:).

Build and run the application and launch RESTed. If you do not have any acronyms in the database, create one now. Then, create a new request configured as follows:

This creates a sibling relationship between the acronym and the category with the provided IDs. You created the category earlier in the chapter.

Click Send Request and you’ll see a 201 Created response:

Querying the relationship

Acronyms and categories are now linked with a sibling relationship. But this isn’t very useful if you can’t view these relationships! Fluent provides functions that allow you to query these relationships. You’ve already used one above to create the relationship.

Acronym’s categories

Open AcronymsController.swift and add a new route handler after addCategoriesHandler(:_):

// 1
func getCategoriesHandler(_ req: Request) 
  -> EventLoopFuture<[Category]> {
  // 2
  Acronym.find(req.parameters.get("acronymID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .flatMap { acronym in
      // 3
      acronym.$categories.query(on: req.db).all()
    }
}

Here’s what this does:

  1. Defines route handler getCategoriesHandler(_:) returning EventLoopFuture<[Category]>.
  2. Get the acronym from the database using the provided ID and unwrap the returned future.
  3. Use the new property wrapper to get the categories. Then use a Fluent query to return all the categories.

Register this route handler at the bottom of boot(routes:):

acronymsRoutes.get(
  ":acronymID", 
  "categories", 
  use: getCategoriesHandler)

This routes an HTTP GET request to /api/acronyms/<ACRONYM_ID>/categories to getCategoriesHandler(:_).

Build and run the application and launch RESTed. Create a request with the following properties:

Send the request and you’ll receive the array of categories that the acronym is in:

Category’s acronyms

Open Category.swift and add a new property annotated with @Siblings below var name: String:

@Siblings(
  through: AcronymCategoryPivot.self, 
  from: \.$category,
  to: \.$acronym)
var acronyms: [Acronym]

Like before, this adds a new property to allow you to query the sibling relationship. @Siblings provides all the required syntactic sugar to set up, query and work with the sibling relationship.

Open CategoriesController.swift and add a new route handler after getHandler(_:):

// 1
func getAcronymsHandler(_ req: Request) 
  -> EventLoopFuture<[Acronym]> {
  // 2
  Category.find(req.parameters.get("categoryID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .flatMap { category in
      // 3
      category.$acronyms.get(on: req.db)
    }
}

Here’s what this does:

  1. Define a new route handler, getAcronymsHandler(_:), that returns EventLoopFuture<[Acronym]>.
  2. Get the category from the database using the ID provided to the request. Ensure one is returned and unwrap the future.
  3. Use the new property wrapper to get the acronyms. This uses get(on:) to perform the query for you. This is the same as query(on: req.db).all() from earlier.

Register this route handler at the bottom of boot(routes:):

categoriesRoute.get(
  ":categoryID", 
  "acronyms", 
  use: getAcronymsHandler)

This routes an HTTP GET request to /api/categories/<CATEGORY_ID>/acronyms to getAcronymsHandler(_:).

Build and run the application and launch RESTed. Create a request as follows:

Send the request and you’ll receive an array of the acronyms in that category:

Removing the relationship

Removing a relationship between an acronym and a category is very similar to adding the relationship. Open AcronymsController.swift and add the following below getCategoriesHandler(req:):

// 1
func removeCategoriesHandler(_ req: Request) 
  -> EventLoopFuture<HTTPStatus> {
  // 2
  let acronymQuery = 
    Acronym.find(req.parameters.get("acronymID"), on: req.db)
      .unwrap(or: Abort(.notFound))
  let categoryQuery = 
    Category.find(req.parameters.get("categoryID"), on: req.db)
      .unwrap(or: Abort(.notFound))
  // 3
  return acronymQuery.and(categoryQuery)
    .flatMap { acronym, category in
      // 4
      acronym
        .$categories
        .detach(category, on: req.db)
        .transform(to: .noContent)
    }
}

Here’s what the new route handler does:

  1. Define a new route handler, removeCategoriesHandler(_:), that returns an EventLoopFuture<HTTPStatus>.
  2. Perform two queries to get the acronym and category from the IDs provided.
  3. Use and(_:) to wait for both futures to return.
  4. Use detach(_:on:) to remove the relationship between acronym and category. This finds the pivot model in the database and deletes it. Transform the result into a 204 No Content response.

Finally, register the route at the bottom of boot(routes:):

acronymsRoutes.delete(
  ":acronymID", 
  "categories", 
  ":categoryID", 
  use: removeCategoriesHandler)

This routes an HTTP DELETE request to /api/acronyms/<ACRONYM_ID>/categories/<CATEGORY_ID> to removeCategoriesHandler(_:).

Build and run the application and launch RESTed. Create a request with the following properties:

Send the request and you’ll receive a 204 No Content response:

If you send the request to get the acronym’s categories again, you’ll receive an empty array.

Where to go from here?

In this chapter, you learned how to implement sibling relationships in Vapor using Fluent. Over the course of this section, you learned how to use Fluent to model all types of relationships and perform advanced queries. The TIL API is fully featured and ready for use by clients.

In the next chapter, you’ll learn how to write tests for the application to ensure that your code is correct. Then, the next section of this book shows you how to create powerful clients to interact with the API — both on iOS and on the web.

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