HeyBinyang

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

·技術·visibility2 閱讀 ·開發

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

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

  • 一對一(一人一檔案)

  • 一對多(一人多文章、一文多評論)

  • 多對多(一文多分類、一分類多文章)

  • 關係欄位 vs 外鍵欄位

  • 巢狀寫入與關係查詢(include / select / relation filter)

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


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

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

  • Prisma 7

  • PostgreSQL 作為資料庫

  • Next.js / Node.js 作為執行環境(本篇程式碼範例用 Node 風格 main() 函式來示範,方便集中講關係)

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

  • User:使用者

  • Profile:使用者檔案(一對一)

  • Post:文章(User 一對多 Post)

  • Comment:評論(Post 一對多 Comment)

  • Category:分類(Post 多對多 Category)

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

  • User 1 — 1 Profile

  • User 1 — n Post

  • Post 1 — n Comment

  • Post m — n Category


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

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

// 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 涵蓋了:

  • 一對一:User–Profile

  • 一對多:User–Post、Post–Comment、User–Comment

  • 多對多:Post–Category(顯式中間表 PostCategory)

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


3. 一對一關係:User – Profile

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

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

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

3.2 Schema 寫法拆解

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]) 指明:

  • 使用本表的 userId 欄位作為外鍵;

  • 對應 User 表的 id 欄位。

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

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

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

3.3 查詢與巢狀寫入範例

查詢某使用者和他的 Profile

const userWithProfile = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    profile: true,
  },
})
  • include.profile = true 告訴 Prisma 一併載入關聯的 Profile;

  • 回傳物件類型中會多出 profile 欄位。

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

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

要點:

  • profile: { create: { ... } } 表示巢狀建立關聯記錄;

  • Prisma 會幫你填好 Profile.userId 外鍵;

  • include 可以直接把剛建立的 Profile 一起回傳。


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

4.1 一對多的通用模式

一對多的核心是:

  • 「多」的那一側有外鍵欄位(如 authorId、postId);

  • 「一」的那一側有陣列關係欄位(如 posts Post[],comments Comment[]);

  • 透過 @relation(fields: [...], references: [...]) 明確外鍵關係。

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

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

要點:

  • Post 有外鍵欄位 authorId Int

  • author User @relation(fields: [authorId], references: [id]) 明確「Post.authorId 引用 User.id」;

  • User 的關係欄位是 posts Post[],陣列類型表示一個使用者有多篇文章。

查詢某使用者及其文章

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

查詢文章時一併帶出作者

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

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

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

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

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

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

  • create:建立新記錄;

  • connect:連接已有記錄。

4.3 Post – Comment(文章的評論)

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

為某篇文章建立一條評論

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

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

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

這裡有一個巢狀 include:

  • include.comments:帶出評論列表;

  • include.comments.include.author:每條評論再帶出作者。


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

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

要點:

  • PostCategory 沒有單獨的自增 id,而是用複合主鍵 @@id([postId, categoryId])

  • assignedAt 是中資料的額外欄位(記錄關聯建立時間);

  • PostCategory.postPostCategory.category 分別透過外鍵欄位 postIdcategoryId 關聯 Post 和 Category;

  • Category 有 posts PostCategory[] 作為反向關係欄位,Post 在前面也有 categories PostCategory[]

5.3 為文章新增分類

1)先建立分類

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

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

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

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

查文章時附帶分類

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

回傳結構類似:

  • post.categoriesPostCategory[]

  • 每個 PostCategory 裡還有 category 物件(真正的 Category 資訊)。

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

查某個分類下的所有文章

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:要什麼資料、拿多少欄位?

  • include:按關係維度「多拿一些表」,控制「帶不帶某個關聯」;

  • select:按欄位維度「少拿一些列」,控制「只要哪些欄位」。

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

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 文章的使用者:

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

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

  • some:至少有一條關聯符合條件;

  • 還有 none(沒有任何關聯符合條件)、every(所有關聯都符合條件)等變體。

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

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

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

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

常見模式:

  • create:在父記錄中建立子記錄;

  • connect:連接已有記錄;

  • connectOrCreate:如果存在就連,不存在就建立;

  • update / delete / set / disconnect 等用於關係更新。

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

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 關係建模裡的幾個關鍵技法:

  • 一對一:透過單側 @unique 外鍵 + 雙向關係欄位表達;

  • 一對多:外鍵在「多」的那一側,陣列關係欄位在「一」的那一側;

  • 多對多:用顯式中間表建模,為「關係本身」留出空間;

  • 巢狀寫入:create / connect 等減少手動拼外鍵;

  • 關係查詢:include / select / relation filter(some / none / every)。

分享