寫 API 時,最怕遇到的情況之一,就是程式沒有語法錯誤,邏輯看起來也正確,但打 API 測試時卻拋出 500 錯誤:
{
"statusCode": 500,
"message": "Internal server error"
}
查看終端機的 log,卻發現一條跟資料庫操作看起來毫無關聯的錯誤訊息:
TypeError: Converting circular structure to JSON
這是使用 QueryBuilder 時容易遇到,卻常讓新手卡關許久的 TypeORM 問題。今天我們就來拆解這個地雷,看看為什麼好端端的資料庫查詢,最後卻死在 JSON 序列化上。
為了呈現今天的問題,我們先定義文章的實體(Entity),可以看到結構非常單純,甚至沒有定義任何關聯:
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
published: boolean;
}
在 PostsService 啟動時先寫入三篇範例文章(兩篇已發布、一篇草稿),並在 getPublishedPosts() 中建立取得已發布文章的查詢:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './post.entity';
const DEMO_POSTS: Array<Pick<Post, 'title' | 'published'>> = [
{ title: 'NestJS 排坑筆記', published: true },
{ title: 'TypeORM QueryBuilder 實戰', published: true },
{ title: '尚未發布的草稿', published: false },
];
@Injectable()
export class PostsService implements OnModuleInit {
constructor(
@InjectRepository(Post)
private readonly postsRepository: Repository<Post>,
) {}
async onModuleInit(): Promise<void> {
await this.postsRepository.save(DEMO_POSTS);
}
getPublishedPosts() {
return this.postsRepository
.createQueryBuilder('post')
.where('post.published = :published', { published: true })
.orderBy('post.id', 'ASC');
}
}
Controller 的實作也很單純,只是將 Service 的結果原封不動交給 Nest 處理:
import { Controller, Get } from '@nestjs/common';
import { PostsService } from './posts.service';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
getPublishedPosts() {
return this.postsService.getPublishedPosts();
}
}
但當我們啟動伺服器,並發送取得文章列表的 HTTP 請求:
GET http://localhost:3000/posts
用戶端不僅沒有收到文章陣列,還會直接得到 500 Internal Server Error,而終端機則印出了前面提到的循環參照錯誤。
createQueryBuilder() 的作用是建立一個 SelectQueryBuilder<Post> 實例。後續呼叫的 where() 或 orderBy(),都是在這個實例上逐步描述 SQL 的結構與參數,它們並不會立刻回傳 Post[]。
這種設計的好處是讓程式能根據條件靈活組合查詢:
const queryBuilder = this.postsRepository.createQueryBuilder('post');
if (publishedOnly) {
queryBuilder.where('post.published = :published', { published: true });
}
queryBuilder.orderBy('post.id', 'ASC');
但要注意的是,組合完條件並不等於完成查詢。這就像是你已經把想吃的餐點都選好了,但最後卻沒有將點餐單「送出」,廚房自然不可能為你上菜。直到呼叫 getMany()、getOne() 等方法,TypeORM 才會真正去執行查詢,並將結果轉換成對應的資料結構。
錯誤版本的程式碼恰恰少了最後這一步送出查詢的動作。因此,它回傳的不是文章陣列,而是整個 QueryBuilder 實例。這個物件除了保存 SQL 查詢條件,也持有 TypeORM 內部使用的 DataSource、ExpressionMap 與 EntityMetadata 等資訊。
當 Controller 將這包龐大的 QueryBuilder 原封不動地交出去後,問題就來了。NestJS 會透過底層的 HTTP adapter(以預設的 Express adapter 為例)來處理 API 回應;只要發現回傳值是物件,adapter 就會自動對其進行 JSON 序列化。
由於 Controller 回傳的是 QueryBuilder 實例,而不是查詢結果,序列化時就可能遇到 TypeORM 內部物件的循環參照。例如 EntityMetadata 與 ColumnMetadata 之間可能存在以下參照關係:
EntityMetadata
└── ownColumns[0]
└── ColumnMetadata
└── entityMetadata
└── 回到原本的 EntityMetadata
這代表 EntityMetadata 持有 ColumnMetadata,而 ColumnMetadata 又指回原本的 EntityMetadata,形成循環參照。
由於 JSON.stringify() 無法直接處理這種結構,因此會拋出 TypeError: Converting circular structure to JSON。
實際出現的循環參照路徑可能因 TypeORM 版本而不同,但問題的根本原因相同:QueryBuilder 是用來建立與執行資料庫查詢的物件,而不是提供給 API 回傳的查詢結果。
這也是為什麼,解決這個問題的方向,不該是嘗試去幫 QueryBuilder 加上能處理循環參照的 JSON replacer。就算你成功清除了循環欄位,用戶端拿到的依然是 ORM 的內部狀態,而不是他們真正需要的文章資料。
要解決這個問題,我們只需在原本的方法中,補上執行查詢的動作即可:
getPublishedPosts(): Promise<Post[]> {
return this.postsRepository
.createQueryBuilder('post')
.where('post.published = :published', { published: true })
.orderBy('post.id', 'ASC')
.getMany(); // 補上查詢執行方法
}
補上 .getMany() 後再次呼叫端點,就會如期得到正常的文章陣列:
[
{
"id": 1,
"title": "NestJS 排坑筆記",
"published": true
},
{
"id": 2,
"title": "TypeORM QueryBuilder 實戰",
"published": true
}
]
以下列出幾種常見的查詢執行方法:
| 需求 | 方法 | 常見回傳型別 |
|---|---|---|
| 多筆實體 | getMany() |
Promise<Post[]> |
| 多筆原始資料 | getRawMany() |
Promise<RawResult[]> |
| 筆數 | getCount() |
Promise<number> |
除了呼叫正確的方法,為方法明確標註回傳型別 也是一道成本極低卻非常有效的防線:
getPublishedPosts(): Promise<Post[]> {
return this.postsRepository
.createQueryBuilder('post')
.where('post.published = :published', { published: true });
}
如果加上回傳型別,上面這段漏寫 .getMany() 的程式碼在編譯階段就會直接產生型別錯誤,因為 SelectQueryBuilder<Post> 無法被指派給 Promise<Post[]>。如果完全依賴 TypeScript 自動推導,編譯器只會忠實地推導出這是一個 QueryBuilder,卻無從得知業務邏輯期待的其實是文章陣列。
因此,對於預期回傳資料的方法,明確標註 Promise<Post[]>,就能在開發階段及早發現誤回傳 QueryBuilder 的問題。這不僅方便閱讀,更能提早在 HTTP 回應送出前幫你攔下這類容易被忽略的錯誤。
createQueryBuilder()、where() 與 orderBy() 的作用在於組合查詢條件;必須呼叫 getMany()、getOne() 等查詢執行方法後,才會真正向資料庫請求並取得實體或其他資料。QueryBuilder 實例交給 HTTP 回應流程,序列化時碰到 ORM 內部的循環參照,才導致 JSON.stringify() 報錯。真正的問題是漏掉查詢執行步驟,不該往調整 JSON 序列化或修改實體關聯的方向修補。Promise<Post[]>,能讓 TypeScript 在編譯階段及早指出誤回 QueryBuilder 的問題。