六维教程

Node.js 数据库设计与关联

上一篇的 User 表是孤立的,现实里的数据都有关联,用户有文章,文章有评论。这篇解决表之间怎么建立关系、怎么写关联查询,以及多个操作怎么放进一个事务。数据模型设计好,后面写 API 才顺。

一对多关系

一个用户能发多篇文章,一篇文章只属于一个用户,这就是一对多。

在 Prisma 里表达这种关系要两边都声明

model User {
  id    Int    @id @default(autoincrement())
  name  String
  posts Post[]
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  content  String
  author   User   @relation(fields: [authorId], references: [id])
  authorId Int
}

关系字段的写法

User 这边写 posts Post[],表示一个用户有多篇文章。Post 这边写 author User,表示这篇文章属于哪个用户。关键在 @relation 注解,fields: [authorId] 说 Post 表上有个叫 authorId 的列,references: [id] 说它指向 User 表的 id 列。

外键字段命名有约定,关系名加 id 后缀,作者是 author 就叫 authorId,删掉两个字段里的任何一个,prisma validate 都会报错。

多对多关系

一篇文章可以打多个标签,一个标签对应多篇文章,这是多对多。Prisma 里写隐式多对多最简单,两边各加一个数组字段

model Post {
  id   Int   @id @default(autoincrement())
  tags Tag[]
}

model Tag {
  name  String @id
  posts Post[]
}

Prisma 会自动建一张关联表 _PostToTag 存两边的主键,不需要手写中间模型。需要额外存关联信息,比如打标签的时间,就要改成显式中间模型,先了解概念即可。

关联查询

include 预加载

查文章时想连作者一起查出来,用 include

const posts = await prisma.post.findMany({
  include: {
    author: true
  }
});
console.log(posts[0].author.name);   // 作者名直接可用

没写 include 时 posts[0].author 是 undefined,Prisma 默认不加载关联数据。

为什么 include 能避免 N+1

不用 include 的写法是先在代码里查文章列表,再循环每条文章单独查一次作者,查 100 条文章就要发 101 次数据库请求,这叫 N+1 查询。include 让 Prisma 用一条带 JOIN 的 SQL 把数据一次拿回来,次数从 N+1 变成 1。

select 只取需要的字段

接口只需要几个字段时用 select 裁剪,顺带还能从关联对象里挑字段

const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    author: {
      select: { name: true }
    }
  }
});

响应体只含 id、title 和 author.name,不会把 content 整篇带出去。

事务处理

删除用户时他的文章也得删,两步操作必须要么全成功要么全失败,用 $transaction 包起来

await prisma.$transaction([
  prisma.post.deleteMany({ where: { authorId: 1 } }),
  prisma.user.delete({ where: { id: 1 } })
]);

中间任何一步报错,数据库自动回滚,不会出现用户没了文章还挂着的情况。交互式写法适合中间需要取值的场景

await prisma.$transaction(async (tx) => {
  const user = await tx.user.findUnique({ where: { id: 1 } });
  if (!user) throw new Error("用户不存在");
  await tx.post.deleteMany({ where: { authorId: user.id } });
  await tx.user.delete({ where: { id: user.id } });
});

事务里要用传入的 tx 对象,不能用外层的 prisma,否则操作不在事务范围内。

级联删除

删用户时把文章一起删掉,可以在模型上声明 onDelete: Cascade,数据库会随用户删除自动清理文章,代价是误删用户会连带删掉全部文章。

model Post {
  author   User @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId Int
}

实践

设计 User-Post-Comment 三张表,文章列表接口带出作者和评论。

model User {
  id       Int       @id @default(autoincrement())
  name     String
  posts    Post[]
  comments Comment[]
}

model Post {
  id       Int       @id @default(autoincrement())
  title    String
  content  String
  author   User      @relation(fields: [authorId], references: [id])
  authorId Int
  comments Comment[]
}

model Comment {
  id       Int    @id @default(autoincrement())
  content  String
  post     Post   @relation(fields: [postId], references: [id])
  postId   Int
  author   User   @relation(fields: [authorId], references: [id])
  authorId Int
}
npx prisma migrate dev --name post_comment

先造数据,创建用户、文章和评论

const user = await prisma.user.create({ data: { name: "小明" } });

const post = await prisma.post.create({
  data: {
    title: "第一篇",
    content: "内容",
    authorId: user.id,
    comments: {
      create: [{ content: "好文", authorId: user.id }]
    }
  }
});

嵌套 create 一次建出文章和评论,authorId 指向同一个用户,这是关联写的一侧。查的功夫在下面。

查询接口用嵌套 include 一次带出两层关系

const posts = await prisma.post.findMany({
  include: {
    author: { select: { name: true } },
    comments: {
      select: { content: true, author: { select: { name: true } } }
    }
  }
});

每条文章带作者名和评论列表,一条查询的功夫完成,不用循环再去查。

常见坑

  • 只在一端声明关系字段,prisma validate 报错,两边都要写。
  • include 里写错关联名,比如把 comments 写成 comment,运行时报 Unknown field。
  • 事务里用外层 prisma 对象,操作不在事务内,回滚不生效。
  • 忘了跑 migrate,新表查询直接报表不存在。
  • 外键字段忘了写 @relation,Prisma 不知道它指向哪张表。

表关系理清后,下一篇解决用户怎么登录,见 Node.js JWT 身份认证

上一篇
Node.js Prisma ORM
下一篇
Node.js JWT 身份认证