Tech2 views

Part 4: Practical Prisma Relationship Modeling & Queries (blog-demo Project)

Starting from this article, we move from a single-table world with only User to a multi-table world with real business semantics: blog-demo blog system.

We will use Prisma to build a classic relationship model: User / Profile / Post / Comment / Category, and explain clearly around it:

This article assumes you are already familiar with the basic Schema syntax (datasource / generator / scalar types / attributes) covered earlier. If you've forgotten, you can always refer back to Part 3.


1. blog-demo Project and Overall Model Design

We continue with the tech stack mental model from previous articles:

The target data model for this article is a simplified blog system:

It will ultimately form the following relationships:


2. blog-demo's schema.prisma: Write it all at once, then break it down section by section

First, let's provide a complete schema version, and then we'll explain each type of relationship writing piece by piece.

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

This schema covers:

Below, we break it down by relationship type.


3. One-to-one Relationship: User – Profile

3.1 One-to-one Concept and Modeling Choices

One-to-one means one user has one profile, and one profile belongs to one user. Common scenario: authentication-related fields are in User, while extended information is in Profile.

In Prisma, the syntax for one-to-one relationships is very similar to one-to-many, except that the relationship field side uses singular + non-array.

3.2 Schema Writing Breakdown

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

Key points:

  1. On Profile, there is a foreign key field userId Int and it is marked with @unique
    This ensures that "one Profile corresponds to one User", preventing multiple profiles from binding to the same user.

  2. The relationship field (object type): user User
    Uses @relation(fields: [userId], references: [id]) to specify:

  1. The relationship field on User: profile Profile?

    • Profile? indicates it's optional (some users may not have a profile yet);

    • No need to write @relation because Prisma can infer from the reverse side.

3.3 Query and Nested Write Examples

Query a user and their Profile

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

Create a Profile while creating a User (nested write)

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

Points:


4. One-to-many Relationship: User – Post, Post – Comment

4.1 One-to-many General Pattern

The core of one-to-many is:

4.2 User – Post (one user has multiple articles)

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

Key points:

Query a user and their articles

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

Query articles and include the author

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

Create a User while creating multiple Posts (nested write)

text
const user = await prisma.user.create({
  data: {
    email: 'writer@example.com',
    name: 'Writer',
    posts: {
      create: [
        { title: 'First Article', content: 'Hello Prisma' },
        { title: 'Second Article', content: 'Modeling relationships is fun' },
      ],
    },
  },
  include: { posts: true },
})

Add a new article to an existing user (connect to existing relationship)

text
const post = await prisma.post.create({
  data: {
    title: 'New Article',
    content: 'Content...',
    author: {
      connect: { id: 1 }, // connect to existing user
    },
  },
})

This demonstrates two common operations in Prisma nested writes:

4.3 Post – Comment (comments on articles)

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 is connected to both Post and User (a comment belongs to an article and also to a user), so it has two foreign keys: postId and authorId.

Create a comment for an article

text
const comment = await prisma.comment.create({
  data: {
    content: 'Well written!',
    post: {
      connect: { id: 123 }, // connect to the article
    },
    author: {
      connect: { id: 1 },   // connect to the user
    },
  },
})

Query an article and its comments (with authors)

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

Here is a nested include:


5. Many-to-many Relationship: Post – Category

5.1 Two Modeling Approaches for Many-to-many

In Prisma, there are two common ways to model many-to-many relationships:

  1. Implicit many-to-many
    Only write categories Category[] and posts Post[] on the two models, and Prisma automatically creates an intermediate table for you.

  2. Explicit many-to-many
    Manually create an intermediate table model, e.g., PostCategory, suitable for when the intermediate table needs extra fields (like ordering, scoring, timestamps).

For generality, this article uses the explicit intermediate table approach (more flexible).

5.2 Schema Writing: 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])
}

Key points:

5.3 Adding Categories to an Article

1) First create categories

text
const tech = await prisma.category.create({
  data: {
    name: 'Technology',
    slug: 'tech',
  },
})
const life = await prisma.category.create({
  data: {
    name: 'Life',
    slug: 'life',
  },
})

2) Attach an article to multiple categories

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

5.4 Query: Article with its Categories / Articles under a Category

Query an article with categories

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

The returned structure is like:

In real projects, if you find this too verbose, you can add a mapping layer in the application to transform PostCategory into a simpler { id, name, slug } type for the frontend.

Query all articles under a category

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

Similarly, here it's Category.posts (PostCategory[]), and each element contains a post field.


6. Key Concepts in Relational Queries: include / select / relation filter

6.1 include vs select: What data do you want? How many fields to get?

Example: when querying articles, only get the author's name, not all fields of the author:

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

Conversely, if you just want to bring in several associations without filtering fields, simply using include: { author: true, comments: true } is sufficient.

6.2 relation filter: Filtering by relationship conditions

Prisma Client supports filtering based on relationship conditions, such as: "find users who have published articles", "find articles with more than 3 comments", etc.

For example: find users who have at least one published article:

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

Here posts.some is a typical relation filter usage:

Another example: find articles with no comments (articles that haven't been discussed):

text
const postsWithoutComments = await prisma.post.findMany({
  where: {
    comments: {
      none: {}, // as long as there are no comments
    },
  },
})

7. Common Patterns of Nested Writes

In actual code, you rarely manually step through "insert User first, then insert Post, then manually write the foreign key". Instead, you more often use Prisma's nested write capabilities:

Common patterns:

Typical example: register a user while creating a Profile and posting a welcome article.

text
const user = await prisma.user.create({
  data: {
    email: 'newuser@example.com',
    name: 'Newbie',
    profile: {
      create: {
        bio: 'New here',
      },
    },
    posts: {
      create: {
        title: 'Hello Blog',
        content: 'My first blog post',
        published: true,
      },
    },
  },
  include: {
    profile: true,
    posts: true,
  },
})

One API request, one transaction, multiple tables are written together, and the Client returns complete nested data.


8. After This Article, You Can Build a Decent Relationship Model for Any "Small Business"

By now, you have mastered several key techniques in Prisma relationship modeling:

SHARE

Share

Share this article.