技術2 閱讀

第 4 篇:Prisma 關聯建模與查詢實戰(blog-demo 專案)

從這一篇開始,我們從只有 User 的單表世界,走向有真實業務語義的多表世界:部落格系統 blog-demo

我們會用 Prisma 建出一套經典關係模型:User / Profile / Post / Comment / Category,並圍繞它講清楚:

本篇預設你已經熟悉前面講過的 Schema 基礎語法(datasource / generator / 標量類型 / 屬性),如果忘了可以隨時翻回第 3 篇。


1. blog-demo 專案與整體模型設計

我們繼續沿用前幾篇的技術棧心智模型:

本篇的目標資料模型是一個簡化部落格系統:

最終會形成下面這樣的關係:


2. blog-demo 的 schema.prisma:一口氣寫完,再逐段拆開

先給出一個完整的 schema 版本,然後我們分塊解釋每種關係寫法。

text
// generator & datasource:與前幾篇保持一致的風格
generator client {
  provider = "prisma-client"
  output   = "../generated/blog"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 使用者表
model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  // 關係欄位
  profile   Profile?
  posts     Post[]
  comments  Comment[]
}

// 使用者檔案(一對一)
model Profile {
  id        Int     @id @default(autoincrement())
  bio       String?
  avatarUrl String?

  userId    Int     @unique
  user      User    @relation(fields: [userId], references: [id])
}

// 文章(一對多:User -> Post)
model Post {
  id        Int        @id @default(autoincrement())
  title     String
  content   String?
  published Boolean     @default(false)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  authorId  Int
  author    User        @relation(fields: [authorId], references: [id])

  comments  Comment[]
  categories PostCategory[]
}

// 評論(一對多:Post -> Comment;User -> Comment)
model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  createdAt DateTime @default(now())

  postId    Int
  post      Post     @relation(fields: [postId], references: [id])

  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
}

// 分類(多對多:Post <-> Category)
model Category {
  id        Int            @id @default(autoincrement())
  name      String
  slug      String         @unique

  posts     PostCategory[]
}

// 顯式中間表(多對多 + 額外欄位的預留空間)
model PostCategory {
  postId     Int
  categoryId Int

  assignedAt DateTime @default(now())

  post       Post     @relation(fields: [postId], references: [id])
  category   Category @relation(fields: [categoryId], references: [id])

  @@id([postId, categoryId])
}

這個 schema 涵蓋了:

下面我們按關係類型拆開解釋。


3. 一對一關係:User – Profile

3.1 一對一的概念與建模選擇

一對一=一個使用者有一個檔案,一個檔案屬於一個使用者。常見場景:鑑權相關欄位在 User,擴充資訊在 Profile。

在 Prisma 裡,一對一關係的寫法和「一對多」的語法很像,只是關係欄位那一側用單數+非陣列

3.2 Schema 寫法拆解

text
model User {
  // ...
  profile   Profile?
}

model Profile {
  id        Int     @id @default(autoincrement())
  bio       String?
  avatarUrl String?

  userId    Int     @unique
  user      User    @relation(fields: [userId], references: [id])
}

關鍵點:

  1. 在 Profile 上有一個外鍵欄位 userId Int,並且加了 @unique
    這保證了「一個 Profile 只對上一個 User」,防止多檔案綁同一個使用者。

  2. 關係欄位(物件類型):user User
    使用 @relation(fields: [userId], references: [id]) 指明:

  1. User 這邊的關係欄位:profile Profile?

    • Profile? 表示可選(一部分使用者可能還沒有檔案);

    • 不需要再寫 @relation,因為 Prisma 可以從反向推斷。

3.3 查詢與巢狀寫入範例

查詢某使用者和他的 Profile

text
const userWithProfile = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    profile: true,
  },
})

在建立 User 時同時建立 Profile(巢狀寫入)

text
const user = await prisma.user.create({
  data: {
    email: 'bob@example.com',
    name: 'Bob',
    profile: {
      create: {
        bio: 'I love Prisma',
        avatarUrl: 'https://example.com/avatar.png',
      },
    },
  },
  include: { profile: true },
})

要點:


4. 一對多關係:User – Post,Post – Comment

4.1 一對多的通用模式

一對多的核心是:

4.2 User – Post(一個使用者多篇文章)

text
model User {
  // ...
  posts    Post[]
}

model Post {
  id        Int        @id @default(autoincrement())
  title     String
  content   String?
  published Boolean     @default(false)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  authorId  Int
  author    User        @relation(fields: [authorId], references: [id])

  comments  Comment[]
  categories PostCategory[]
}

要點:

查詢某使用者及其文章

text
const userWithPosts = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: true,
  },
})

查詢文章時一併帶出作者

text
const posts = await prisma.post.findMany({
  include: {
    author: true,
  },
})

建立 User 的同時建立多篇 Post(巢狀寫入)

text
const user = await prisma.user.create({
  data: {
    email: 'writer@example.com',
    name: 'Writer',
    posts: {
      create: [
        { title: '第一篇文章', content: 'Hello Prisma' },
        { title: '第二篇文章', content: '關係建模真好玩' },
      ],
    },
  },
  include: { posts: true },
})

給已有使用者新增一篇文章(連接已有關係)

text
const post = await prisma.post.create({
  data: {
    title: '新文',
    content: '內容...',
    author: {
      connect: { id: 1 }, // 連接已有使用者
    },
  },
})

這裡體現了 Prisma 巢狀寫入裡的兩個常用操作:

4.3 Post – Comment(文章的評論)

text
model Post {
  // ...
  comments  Comment[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  createdAt DateTime @default(now())

  postId    Int
  post      Post     @relation(fields: [postId], references: [id])

  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
}

Comment 同時連著 Post 和 User(一條評論屬於某篇文章,也屬於某個使用者),所以有兩個外鍵:postIdauthorId

為某篇文章建立一條評論

text
const comment = await prisma.comment.create({
  data: {
    content: '寫得不錯!',
    post: {
      connect: { id: 123 }, // 關聯文章
    },
    author: {
      connect: { id: 1 },   // 關聯使用者
    },
  },
})

查詢一篇文章及其評論(附帶作者)

text
const postWithComments = await prisma.post.findUnique({
  where: { id: 123 },
  include: {
    comments: {
      include: {
        author: true,
      },
    },
  },
})

這裡有一個巢狀 include:


5. 多對多關係:Post – Category

5.1 多對多的兩種建模方式

在 Prisma 裡,多對多關係有兩種常見方式:

  1. 隱式多對多(implicit many-to-many)
    只在兩個模型上寫 categories Category[]posts Post[],Prisma 幫你自動建立中間表。

  2. 顯式多對多(explicit many-to-many)
    手動建立中間表模型,例如 PostCategory,適合中間表需要額外欄位(比如排序、打分、時間戳)時。

本篇為了通用性,採用顯式中間表方式(更靈活)。

5.2 Schema 寫法:Category + PostCategory

text
model Category {
  id        Int            @id @default(autoincrement())
  name      String
  slug      String         @unique

  posts     PostCategory[]
}

model PostCategory {
  postId     Int
  categoryId Int

  assignedAt DateTime @default(now())

  post       Post     @relation(fields: [postId], references: [id])
  category   Category @relation(fields: [categoryId], references: [id])

  @@id([postId, categoryId])
}

要點:

5.3 為文章新增分類

1)先建立分類

text
const tech = await prisma.category.create({
  data: {
    name: '技術',
    slug: 'tech',
  },
})
const life = await prisma.category.create({
  data: {
    name: '生活',
    slug: 'life',
  },
})

2)把某篇文章掛到多個分類下

text
const postCategoryLinks = await prisma.postCategory.createMany({
  data: [
    { postId: 123, categoryId: tech.id },
    { postId: 123, categoryId: life.id },
  ],
  skipDuplicates: true,
})

5.4 查詢:文章及其分類 / 分類下的文章

查文章時附帶分類

text
const post = await prisma.post.findUnique({
  where: { id: 123 },
  include: {
    categories: {
      include: {
        category: true,
      },
    },
  },
})

回傳結構類似:

在實際專案裡,如果你覺得這太囉嗦,可以在應用層做一層映射,把 PostCategory 轉成更簡單的 { id, name, slug } 類型給前端用。

查某個分類下的所有文章

text
const categoryWithPosts = await prisma.category.findUnique({
  where: { slug: 'tech' },
  include: {
    posts: {
      include: {
        post: true,
      },
    },
  },
})

同理,這裡是 Category.posts(PostCategory[]),每個元素裡再有 post 欄位。


6. 關係查詢裡的幾個關鍵概念:include / select / relation filter

6.1 include vs select:要什麼資料、拿多少欄位?

例子:查文章時只拿作者的名稱,而不拿作者的所有欄位:

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

相反,如果你只是想多帶幾個關聯,但欄位懶得篩,只要 include: { author: true, comments: true } 就夠用。

6.2 relation filter:依關聯條件篩選

Prisma Client 支援依關聯條件篩選,比如:「找出有已發布文章的使用者」,「找出有超過 3 條評論的文章」等。

例如:找出擁有至少一篇 published 文章的使用者:

text
const usersWithPublished = await prisma.user.findMany({
  where: {
    posts: {
      some: {
        published: true,
      },
    },
  },
})

這裡的 posts.some 是典型 relation filter 的寫法:

再比如:找出沒有任何評論的文章(沒有「被討論」的文章):

text
const postsWithoutComments = await prisma.post.findMany({
  where: {
    comments: {
      none: {}, // 沒有任何 comment 即可
    },
  },
})

7. 巢狀寫入的幾個常見模式

你在實際程式碼中,很少手動分步做「先插入 User,再插入 Post,再手寫外鍵」,而是更常用 Prisma 的巢狀寫入能力:

常見模式:

典型例子:註冊一個使用者的同時,建立 Profile 並發一篇歡迎文章。

text
const user = await prisma.user.create({
  data: {
    email: 'newuser@example.com',
    name: 'Newbie',
    profile: {
      create: {
        bio: '新來的',
      },
    },
    posts: {
      create: {
        title: 'Hello Blog',
        content: '我的第一篇部落格',
        published: true,
      },
    },
  },
  include: {
    profile: true,
    posts: true,
  },
})

一個 API 請求,一次事務,多張表一起寫完,同時 Client 回傳完整的巢狀資料。


8. 這一篇之後,你已經可以為任何「小型業務」建出一套像樣的關係模型

到這裡,你已經掌握了 Prisma 關係建模裡的幾個關鍵技法:

SHARE

分享

分享這篇文章。