Skip to content

Xây dựng Types thông minh trong Next.js (Phần 3): Mở rộng quan hệ giữa nhiều bảng

Type của một bài viết "post" kèm "author", "tags", và "media" trông như thế nào?
Hai
đã xây dựng xong type system cho một bảng posts đơn giản. Nhưng trong thực tế, một bài viết hiếm khi đứng một mình. Nó có tác giả, có tags phân loại, có ảnh hoặc video đính kèm.
Khi bạn query posts kèm theo các bảng liên quan, kết quả trả về không còn là một flat object nữa — nó là một cây dữ liệu lồng nhau. Và type system của bạn cũng phải phản ánh điều đó.
Câu hỏi thực tế xuất hiện ngay: type của một bài viết kèm author, tags, và media trông như thế nào? Viết tay hay để Drizzle infer? Nếu để Drizzle infer thì infer bằng cách nào?
Bài này trả lời từng câu.

Bức tranh tổng thể

Chúng ta sẽ làm việc với bốn bảng:
posts — bài viết (đã có từ bài trước)
authors — tác giả
tags — danh sách tag
post_tags — bảng trung gian, nhiều-nhiều giữa poststags
post_media — ảnh/video đính kèm, quan hệ một-nhiều với posts
Quan hệ giữa chúng:
posts ──── belongs to ────▶ authors
posts ──── has many ──────▶ post_media
posts ──── has many ──────▶ post_tags ──── belongs to ────▶ tags

Bước 1: Định nghĩa schema cho tất cả các bảng

// src/db/schema.ts
import {
mysqlTable, varchar, timestamp, mysqlEnum,
char, int, primaryKey,
} from 'drizzle-orm/mysql-core'
import { relations } from 'drizzle-orm'

// --- Authors ---
export const authors = mysqlTable('authors', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
avatarUrl: varchar('avatarUrl', { length: 1000 }),
createdAt: timestamp('createdAt').notNull(),
})

// --- Tags ---
export const tags = mysqlTable('tags', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(),
})

// --- Posts ---
export const posts = mysqlTable('posts', {
id: char('id', { length: 36 }).notNull().primaryKey(),
title: varchar('title', { length: 255 }),
body: varchar('body', { length: 5000 }),
status: mysqlEnum('status', ['draft', 'published']).notNull().default('draft'),
authorId: char('authorId', { length: 36 }).notNull(),
createdAt: timestamp('createdAt').notNull(),
})

// --- Post Tags (junction table) ---
export const postTags = mysqlTable('post_tags', {
postId: char('postId', { length: 36 }).notNull(),
tagId: char('tagId', { length: 36 }).notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.postId, t.tagId] }),
}))

// --- Post Media ---
export const postMedia = mysqlTable('post_media', {
id: char('id', { length: 36 }).notNull().primaryKey(),
postId: char('postId', { length: 36 }).notNull(),
url: varchar('url', { length: 1000 }).notNull(),
type: mysqlEnum('type', ['image', 'video']).notNull(),
order: int('order').notNull().default(0),
createdAt: timestamp('createdAt').notNull(),
})
Sau đó khai báo relations — đây là bước bắt buộc để Drizzle hiểu được cấu trúc khi dùng relational query với with:
// src/db/relations.ts
import { relations } from 'drizzle-orm'
import { posts, authors, tags, postTags, postMedia } from './schema'

export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(authors, {
fields: [posts.authorId],
references: [authors.id],
}),
postTags: many(postTags),
media: many(postMedia),
}))

export const authorsRelations = relations(authors, ({ many }) => ({
posts: many(posts),
}))

export const tagsRelations = relations(tags, ({ many }) => ({
postTags: many(postTags),
}))

export const postTagsRelations = relations(postTags, ({ one }) => ({
post: one(posts, { fields: [postTags.postId], references: [posts.id] }),
tag: one(tags, { fields: [postTags.tagId], references: [tags.id] }),
}))

export const postMediaRelations = relations(postMedia, ({ one }) => ({
post: one(posts, { fields: [postMedia.postId], references: [posts.id] }),
}))
relations() không tạo ra foreign key trong DB — đó là việc của migration. Nó chỉ nói với Drizzle query builder: “khi tôi dùng with, hãy join theo cách này.”

Bước 2: Infer base row type cho từng bảng

// src/types/post.ts
import { posts, authors, tags, postTags, postMedia } from '@/db/schema'

export type PostRow = typeof posts.$inferSelect
export type NewPostRow = typeof posts.$inferInsert

export type AuthorRow = typeof authors.$inferSelect
export type TagRow = typeof tags.$inferSelect
export type PostTagRow = typeof postTags.$inferSelect
export type PostMediaRow = typeof postMedia.$inferSelect
Cùng nguyên tắc từ bài trước: schema là nguồn sự thật, type được infer từ đó — không viết tay.

Bước 3: Infer type cho relational query

Đây là phần mới. Khi bạn dùng db.query.posts.findMany({ with: { author: true, ... } }), kết quả trả về có shape lồng nhau. Để có được type đó mà không phải tự mô tả lại, có hai cách.

Cách 1: Infer từ chính câu query (đơn giản, thực tiễn)

// src/db/queries/post.ts
import { db } from '@/db'

// Khai báo query như một const — chưa await
const postWithRelationsQuery = db.query.posts.findFirst({
with: {
author: true,
postTags: {
with: { tag: true },
},
media: true,
},
})

// Infer type từ return type của query đó
export type PostWithRelations = NonNullable<Awaited<typeof postWithRelationsQuery>>
Awaited<> unwrap Promise, NonNullable<> loại bỏ undefined (vì findFirst có thể trả về undefined). Kết quả là một type tự động mô tả đúng shape của dữ liệu trả về, bao gồm cả nested relations.
Shape thực tế của PostWithRelations sẽ là:
type PostWithRelations = {
id: string
title: string | null
body: string | null
status: 'draft' | 'published'
authorId: string
createdAt: Date
author: {
id: string
name: string
email: string
avatarUrl: string | null
createdAt: Date
}
postTags: {
postId: string
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.