跳转至

第10章:兄弟姐妹关系

在第9章"父子关系"中,你学会了如何使用Fluent来建立模型之间的父子关系。本章告诉你如何实现另一种关系:兄弟姐妹关系。你将学习如何在Vapor中为它们建模,以及如何在路由中使用它们。

Note

本章要求你已经设置并配置了PostgreSQL。按照第6章"配置数据库"的步骤,在Docker中设置PostgreSQL并配置Vapor应用程序。

兄弟姐妹关系

兄弟姐妹关系描述了一种将两个模型相互连接的关系。它们也被称为多对多关系。与父子关系不同,在兄弟姐妹关系中,模型之间没有约束。

例如,如果你对宠物和玩具之间的关系进行建模,一个宠物可以有一个或多个玩具,一个玩具可以被一个或多个宠物使用。在TIL应用程序中,你将能够对首字母缩写词进行分类。一个首字母缩写可以是一个或多个类别的一部分,一个类别可以包含一个或多个首字母缩写。

创建一个类别

为了实现分类,你需要创建一个模型、一个迁移、一个控制器和一个枢纽。首先要创建模型。

类别模型

Xcode中,在Sources/App/Models创建一个新文件Category.swift。打开该文件并插入一个类别的基本模型:

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
  }
}

该模型包含一个String属性,用来保存类别的名称。模型还包含一个可选的id属性,当它被设置时,存储模型的ID。你用它们各自的属性包装器来注释这两个属性。

接下来,在Sources/App/Migrations创建一个新文件CreateCategory.swift。在这个新文件中插入以下内容:

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()
  }
}

现在你应该明白了 它使用与模型中定义的schema相同的值来创建表,并带有必要的属性。迁移时在revert(on:)中删除了该表。

最后,打开configure.swift,在app.migrations.add(CreateAcronym())之后,将CreateCategory加入到迁移列表中:

app.migrations.add(CreateCategory())

这将把新的迁移添加到应用程序的迁移中,以便Fluent在下次启动应用程序时在数据库中创建该表。

类别控制器

现在是创建控制器的时候了。在Sources/App/Controllers中,创建一个名为CategoriesController.swift的新文件。打开该文件,为一个新的控制器添加代码,以创建和检索类别:

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))
  }
}

以下是控制器的作用:

  1. 定义一个新的CategoriesController类型,符合RouteCollection
  2. 按照RouteCollection的要求实现boot(routes:)。这是你注册路由处理程序的地方。
  3. 为路径/api/categories创建一个新的路由组。
  4. 将路由处理程序注册到它们的路由。
  5. 定义createHandler(_:),创建一个类别。
  6. 从请求中解码类别并保存它。
  7. 定义getAllHandler(_:),返回所有的类别。
  8. 执行一个Fluent查询,从数据库中获取所有的类别。
  9. 定义getHandler(_:),返回单一类别。
  10. 从请求中获取ID,并使用它来查找类别。

最后,打开routes.swift,在routes(_:)的末尾添加以下内容来注册控制器:

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

与前几章一样,这将实例化一个控制器,并将其与应用程序注册,以启用其路由。

建立并运行应用程序,然后在RESTed中创建一个新的请求。配置该请求如下:

添加一个带有名称和值的单一参数:

  • name: Teenager

发送请求,你将在响应中看到保存的类别:

img

创建一个枢纽

在第9章"父子关系"中,你在首字母缩写中添加了对用户的引用,以创建首字母缩写和用户之间的关系。然而,你不能像这样建立兄弟姐妹关系的模型,因为这样的查询效率太低。如果你有一个类别内的首字母缩写数组,要搜索一个首字母缩写的所有类别,你就必须检查每一个类别。如果你在一个缩写里面有一个类别数组,要搜索一个类别中的所有缩写,你就必须检查每一个缩写。你需要一个单独的模型来保持这种关系。在Fluent中,这是一个pivot

枢轴是Fluent中的另一种模型类型,包含了这种关系。在Xcode中,在Sources/App/Models中创建这个名为AcronymCategoryPivot.swift的新模型文件。打开AcronymCategoryPivot.swift并添加以下内容来创建透视:

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()
  }
}

以下是这个模型的作用:

  1. 定义一个新的对象AcronymCategoryPivot,符合Model
  2. 为该模型定义一个id。注意这是一个UID类型,所以你必须导入基金会模块。
  3. 定义两个属性以链接到AcronymCategory。你用@Parent属性包装器来注释这些属性。一个枢轴记录只能指向一个Acronym和一个Category,但每个类型都可以指向多个枢轴。
  4. 按照Model的要求,实现空初始化器。
  5. 实现一个初始化器,将两个模型作为参数。这使用requireID()来确保模型有一个ID设置。

接下来为枢轴创建迁移。在Sources/App/Migrations创建一个新文件,CreateAcronymCategoryPivot.swift。打开这个新文件,插入以下内容:

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()
  }
}

以下是新迁移的作用:

  1. 定义一个新的类型,CreateAcronymCategoryPivot,符合Migration
  2. 按照Migration的要求实现prepare(on:)
  3. 使用为AcronymCategoryPivot定义的模式名称选择表。
  4. 创建ID列。

  5. 为两个属性创建两列。这两个列使用提供给属性包装器的键,将类型设置为UUID,并将该列标记为必需。它们还设置了对各自模型的引用,以创建一个外键约束。正如第9章"父子关系"中所说,在兄弟姐妹关系中使用外键约束是一个很好的做法。目前的AcronymCategoryPivot没有检查缩略语和类别的ID。如果没有这个约束,你可以删除仍然由枢轴连接的首字母缩写和类别,这种关系将继续存在,而不会出现错误提示。当你删除模型的时候,迁移还设置了一个级联模式参考动作。这将导致数据库自动删除这个关系,而不是抛出一个错误。

  6. 调用create()来在数据库中创建表。
  7. 按照Migration的要求执行revert(on:)。这将删除数据库中的表。

最后,打开configure.swift,在app.migrations.add(CreateCategory())之后,将CreateAcronymCategoryPivot加入到迁移列表:

app.migrations.add(CreateAcronymCategoryPivot())

这样就把新的pivot模型添加到了应用程序的迁移中,这样Fluent就会在下一次应用程序启动时在数据库中准备好这个表。

要真正在两个模型之间建立关系,需要使用透视器。Fluent提供了创建和删除关系的便利函数。首先,打开Acronym.swift,在模型下面添加一个新的属性var use: User

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

这增加了一个新属性,允许你查询兄弟姐妹关系。你用@Siblings属性包装器来注释这个新属性。@Siblings有三个参数:

  • 枢轴的模型类型
  • 引用根模型的pivot的关键路径。在这种情况下,你使用AcronymCategoryPivotacronym属性。
  • 来自引用相关模型的枢轴的关键路径。在这种情况下,你使用AcronymCategoryPivotcategory属性。

@Parent一样,@Siblings允许你将相关模型指定为一个属性,而不需要他们初始化一个实例。该属性包装器还告诉Fluent在数据库中执行查询时如何映射兄弟姐妹。

@Parent使用数据库中的父级ID列时,@Siblings必须在两个不同的模型和数据库中的透视之间进行连接。值得庆幸的是,Fluent为你抽象出了这一点,让你轻松搞定

打开AcronymsController.swift,在getUserHandler(_:)下面添加以下路由处理程序,以设置缩写和类别之间的关系:

// 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)
    }
}

以下是路线处理程序的工作:

  1. 定义一个新的路由处理程序,addCategoriesHandler(_:),它返回EventLoopFuture<HTTPStatus>
  2. 定义两个属性来查询数据库,并从提供给请求的ID中获得缩写和类别。每个属性都是一个`EventLoopFuture'。
  3. 使用and(_:)来等待两个future的返回。
  4. 使用attach(_:on:)来建立acronymcategory之间的关系。这将创建一个透视模型,并将其保存在数据库中。将结果转化为201 Created响应。像Fluent的许多操作一样,你在属性包装器上调用attach(_:on:)预测值,而不是属性本身。

boot(routes:)的底部注册这个路由处理程序:

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

这将HTTP POST请求路由到/api/acronyms/<ACRONYM_ID>/categories/<CATEGORY_ID>addCategoriesHandler(_:)

构建并运行应用程序,启动RESTed。如果你的数据库中没有任何缩略语,现在就创建一个。然后,创建一个新的请求,配置如下:

这就在缩写和具有所提供的ID的类别之间建立了兄弟姐妹关系。你在本章的早些时候创建了这个类别。

点击Send Request,你会看到一个201 Created的响应:

img

查询关系

缩略语和类别现在是以兄弟姐妹的关系联系起来的。但如果你不能查看这些关系,这就不是很有用了! Fluent提供了一些函数,允许你查询这些关系。你已经使用了上面的一个函数来创建关系。

缩略语的类别

打开AcronymsController.swift,在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()
    }
}

下面是这个的作用:

  1. 定义路由处理程序getCategoriesHandler(_:)返回EventLoopFuture<[Category]>
  2. 使用提供的ID从数据库中获取首字母缩写,并解开返回的未来。
  3. 使用新的属性包装器来获取类别。然后使用Fluent查询来返回所有的类别。

boot(routes:)的底部注册这个路由处理程序:

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

这将HTTP GET请求路由到/api/acronyms/<ACRONYM_ID>/categoriesgetCategoriesHandler(:_)

建立并运行应用程序,并启动RESTed。创建一个具有以下属性的请求:

发送请求,你就会收到该首字母缩写所处的类别数组:

img

类别的首字母缩写

打开Category.swift,在var name: String下面添加一个新的属性,注释为@Siblings

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

像以前一样,这增加了一个新属性,允许你查询兄弟姐妹关系。@Siblings提供了所有必要的语法糖来设置、查询和处理兄弟姐妹关系。

打开CategoriesController.swift,在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)
    }
}

下面是这个的作用:

  1. 定义一个新的路由处理程序,getAcronymsHandler(_:),返回EventLoopFuture<[Acronym]>
  2. 使用提供给请求的ID从数据库中获取类别。确保返回一个,并解开未来。
  3. 使用新的属性包装器来获取首字母缩写。这使用get(on:)来为你执行查询。这与前面的query(on: req.db).all()相同。

boot(routes:)的底部注册这个路由处理程序:

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

这将HTTP GET请求路由到/api/categories/<CATEGORY_ID>/acronymsgetAcronymsHandler(_:)

建立并运行应用程序,并启动RESTed。创建一个请求,如下:

发送请求,你就会收到该类别中的缩略语阵列:

img

移除关系

删除一个缩写和一个类别之间的关系与添加关系非常相似。打开AcronymsController.swift,在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)
    }
}

以下是新的路径处理程序的作用:

  1. 定义一个新的路由处理程序,removeCategoriesHandler(_:),返回一个EventLoopFuture<HTTPStatus>
  2. 执行两个查询,从提供的ID中获得缩写和类别。
  3. 使用and(_:)来等待两个future的返回。
  4. 使用detach(_:on:)删除首字母缩写类别之间的关系。这可以找到数据库中的枢轴模型并删除它。将结果转化为204 No Content响应。

最后,在boot(route:)的底部注册路由:

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

这将HTTP DELETE请求路由到/api/acronyms/<ACRONYM_ID>/categories/<CATEGORY_ID>removeCategoriesHandler(_:)

构建并运行应用程序,启动RESTed。创建一个具有以下属性的请求:

发送请求,你会收到204 No Content的回复:

img

如果你再次发送请求来获取缩写的类别,你会收到一个空数组。

接下来去哪?

在本章中,你学习了如何使用FluentVapor中实现兄弟姐妹关系。在这一节中,你学会了如何使用Fluent对所有类型的关系进行建模并执行高级查询。TILAPI功能齐全,可以供客户使用。

在下一章,你将学习如何为应用程序编写测试,以确保你的代码是正确的。然后,本书的下一节将向你展示如何创建强大的客户端来与API进行交互--无论是在iOS上还是在网络上。