昨天結尾我說,那個 = NULL 的真相是「逼 AI 把原始碼攤開來看」才得出的。今天把整個過程完整拆開——因為真正值得學的不是結論,而是「怎麼逼」的那套步驟。
而且我要先老實說一件昨天沒講的事:問 AI 之前,我其實已經知道答案了。
情境是這樣:一個舊專案要從 Sequelize 4 升到 6,跨了兩個大版本。
(先給不熟的讀者一句話:Sequelize 是 Node.js 上最常用的 ORM 之一。ORM 的作用是讓你用寫物件的方式操作資料庫,它在背後自動幫你把這些操作翻譯成 SQL——方便,但也代表「你寫的」和「它實際送出的 SQL」之間隔了一層,這層翻譯正是今天故事的關鍵。)
升級這種舊專案,最怕的不是會報錯的地方,而是行為悄悄變了、但程式照跑不誤的地方。我盯上的疑點是:當查詢條件的值是 undefined 時,Sequelize 會怎麼處理?
這在舊程式碼裡是家常便飯:
const where = {
status: req.query.status, // 使用者沒帶這個參數時,就是 undefined
type: req.query.type
};
const result = await Model.findAll({ where });
當 req.query.status 是 undefined,這個條件會變成什麼?
這裡要先講一個關鍵背景,否則你會覺得這段程式碼寫得很隨便:在 Sequelize 4.x 的時代,這樣寫是完全合理、甚至是社群主流的做法。 因為 4.x 遇到值為 undefined 的條件,行為就是「直接忽略這個條件」——它不會出現在最後的 SQL 裡。
這帶來一個很方便的寫法:你可以把一堆「使用者可能有帶、也可能沒帶」的查詢參數,全部一股腦丟進 where,沒帶到的自然被忽略,剩下有值的就自動組成查詢條件。不用寫一堆 if (status) where.status = status 的判斷,程式碼乾淨得很。所以在舊專案裡,這種寫法到處都是——它不是偷懶,是當年的正確答案。
問題就出在這裡:如果 4.x「忽略」的行為,到了 6.x 變了樣,那專案裡成千上百個依賴這個行為的動態查詢,全部都會默默出錯——而且不會報錯,因為語法一直都對。
所以我做的第一件事,不是問 AI,是自己寫了一支最小的測試腳本跑跑看。 Sequelize 可以把它實際送出的 SQL 印出來,與其猜,不如直接看:
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory:', {
logging: console.log // 把實際送出的 SQL 印出來
});
const T = sequelize.define('T', { status: DataTypes.STRING });
(async () => {
await sequelize.sync();
await T.findAll({ where: { status: undefined } }); // 關鍵測試
})();
在實際要升級的那個版本上跑,它印出來的 SQL 是:
WHERE `T`.`status` = NULL
我當下就知道這是個大坑(原因等一下說)。手上有了這個客觀事實,我才去問 AI——不是為了找答案,是想看看:如果一個工程師遇到這個問題、又剛好沒像我一樣先測,AI 會把他帶到哪裡去。
我問 AI:Sequelize 6 遇到 undefined 的查詢條件會怎麼處理?
第一個答案:「新版會忽略這個條件。」
聽起來很合理。但我知道它錯了——因為我的腳本明明印出了 = NULL,條件沒有被忽略。
我沒有直接戳破,而是換個問法多問一句:「是『忽略』,還是『視為 NULL 來查詢』?」——想看它會不會堅持。結果它改口了,說是後者,會被當成 IS NULL 處理。
這一改口很關鍵。我沒有給它任何新資訊,只是把選項攤開,它就從 A 跳到 B。這證實了我的猜測:它的答案不是查證來的,是順著對話生成的。而且更糟——它第二個答案 IS NULL 聽起來比第一個更專業、更具體,一個沒有實測資料的工程師,很可能就在這裡被說服了。
把三種說法並排,你會看到這個差異有多致命:
| 說法 | 實際 SQL | 查詢結果 |
|---|---|---|
| AI 第一次「忽略」 | 條件不出現 | 正常撈出符合其他條件的資料 |
| AI 第二次「IS NULL」 | WHERE status IS NULL |
撈出 status 為空的資料 |
| 實測真相「= NULL」 | WHERE status = NULL |
永遠零筆 |
= NULL 是最壞的結果= NULL 在 SQL 標準裡是一個永遠不成立的條件。
原因是 SQL 的三值邏輯:NULL 代表「未知」,任何值跟「未知」做等號比較,結果都不是 true 或 false。判斷欄位是否為空,正確寫法是 IS NULL,不是 = NULL。
所以 WHERE status = NULL 的實際效果是:這個查詢永遠回傳零筆。 不報錯、不拋例外,只是讓某些查詢莫名其妙查不到資料。使用者說「明明有資料卻查不到」,你在程式碼裡怎麼看都看不出問題,因為語法完全正確。這種 bug 可以吃掉你一整天。
三種結果裡,「忽略」還能運作、「IS NULL」至少行為明確,唯獨實測的 = NULL 是那個會靜默出錯、最難抓的。而 AI 兩次都沒指向它。
(補一句:不同版本、不同資料庫方言的處理不完全一樣,有些情況會幫你轉成 IS NULL。這正是重點——行為依賴版本和方言,所以不能靠記憶,只能在你要升級的那個版本上實測。)
到這裡,事實已經很清楚了——我有腳本、有 SQL、有結論。
看到 = NULL 的那一刻,我心裡已經有底了。所以我對它說了那句話:「不要順著我的話說,去看原始碼。」
它這才把對應版本處理 where 條件的原始碼攤開,開始真正解釋這個行為的成因——哪個版本、哪段邏輯、為什麼組出來的是 = NULL。到這一步,它才從「順著我生成答案」切換成「根據事實解釋」,昨天 Day 1 收尾的那個畫面,就是這裡。
這段其實已經是結果、不是今天的重點——真正的重點是它前面那整套:先自己實測拿到事實,再拿事實去測 AI。 讀原始碼只是收尾的確認,讓它在正確的事實上把成因補完整而已。
回頭看,整件事有一個清楚的先後:
這裡面藏著一個我後來一直用的準則:AI 該是被事實驗證的一方,不是提供事實的一方。
它很擅長幫你寫那支驗證腳本、很擅長在你給了正確事實後把成因解釋得清清楚楚——這些都是它的強項,是它的「1」。但「事實到底是什麼」,這件事不能外包給它。你得自己去拿,再回頭要求它在這個事實上工作——這是人補上的那一份,也是讓協作產生加成的地方。
讓它當你的手,不要讓它當你的眼睛。這是這個系列會反覆出現的第一條紀律。
明天換一個角度:當 AI 給出一個「聽起來完全正確的優化建議」時,為什麼規模是你該追問的第一件事——一個關於 Promise.all 的故事,一個正確的建議如何在真實資料量下變成壓垮資料庫的一擊。