iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 27 篇

Day 27|失控的序列化:為什麼回傳 QueryBuilder 會引發循環參照錯誤?

  • 分享至 

  • xImage
  •  

寫 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,而終端機則印出了前面提到的循環參照錯誤。

根因:QueryBuilder 本身並不是查詢結果

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 回應送出前幫你攔下這類容易被忽略的錯誤。

總結

  1. QueryBuilder 不是查詢結果:createQueryBuilder()、where() 與 orderBy() 的作用在於組合查詢條件;必須呼叫 getMany()、getOne() 等查詢執行方法後,才會真正向資料庫請求並取得實體或其他資料。
  2. JSON 序列化錯誤只是表象:Nest 將收到的 QueryBuilder 實例交給 HTTP 回應流程,序列化時碰到 ORM 內部的循環參照,才導致 JSON.stringify() 報錯。真正的問題是漏掉查詢執行步驟,不該往調整 JSON 序列化或修改實體關聯的方向修補。
  3. 明確型別是最後一道防線:替 Service 與 Controller 方法標註 Promise<Post[]>,能讓 TypeScript 在編譯階段及早指出誤回 QueryBuilder 的問題。

參考資料


上一篇
Day 26|消失的全域防線:為什麼加了局部 Filter 後,Global Exception Filter 就不再觸發?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言