本篇是故事四的「重現」篇。
本篇要回答:一行「等價」的 Python 改寫,為什麼會改變 ORM 查詢的語意?
Day 16 的事故軌跡,完整攤開是七層:
Python 原始碼
↓
lint 規則判斷(把這行當成一般布林比較)
↓
自動改寫
↓
ORM Expression(語意在這裡分岔)
↓
SQL 產生
↓
資料庫執行
↓
查詢結果(合法、無例外、永遠為空)
關鍵在第四層:lint 工具看見的是 Python 語法;ORM 看見的是查詢表達式。同一行字,兩套語意。
我原本以為「布林比較怎麼寫都一樣」。這在純 Python 語境大致成立,但 ORM 的欄位物件靠運算子多載(operator overloading)工作——而 Python 的運算子,可多載的範圍是有邊界的。
不需要資料庫,一段標準庫就能重現機制(以下皆實際執行驗證):
class Column: # 模擬 ORM 欄位的最小示意
def __init__(self, name):
self.name = name
def __eq__(self, other): # == 可以多載:回傳「查詢條件」
return f"SQL: {self.name} = {other!r}"
flag = Column("flag")
print(flag == True) # SQL: flag = True ← == 被多載成條件物件
print(flag is True) # False ← is 不可多載,當場變成純 Python bool
print(bool(flag)) # True ← 物件的真值判斷,也與欄位值無關
三行輸出說完了整個事故家族的機制:== 這類比較運算子可以被 ORM 攔截,轉成將來送給資料庫的條件;is 與 not 這類運算不可多載,在 Python 層當場求值成一個普通布林——ORM 根本沒有機會參與。一旦某次改寫讓條件在 Python 層先變成了 False 之類的常數,ORM 拿到的就不是「flag 欄位等於真」的條件,而是一個恆假(或恆真)的字面值,查詢自然永遠為空(或永遠全撈)。
(示意聲明:Column 是機制示範,不是當時的程式碼;真實 ORM 的行為以其官方文件為準,但「== 可多載、is 不可多載」是 Python 語言層的事實,適用於所有 ORM。另外提醒:這段示意只多載了 ==、沒有一併定義雜湊方法,實例會因此變成不可雜湊,放進 set 或當成 dict 的鍵會拋 TypeError: unhashable type(已實測)。真實 ORM 會另行處理這件事,這段程式碼只用來觀察機制,不宜直接照抄。)
四個核心問題就位:哪一層第一次改變語意?自動改寫落地、表達式被重新求值的那一刻。工具知道這是 ORM DSL 嗎?不知道,它只看語法。產生的 SQL 與預期一致嗎?對照事故前後的 SQL 是唯一可靠的判準。為什麼沒有例外?因為每一層都在正確執行自己收到的東西——收到的東西本身錯了。
區分證據等級。已確認事實:上述範例輸出與 Python 運算子多載規則。合理推論:當時事故沿同一機制發生。已不可考:當時的確切改寫方向(Day 17)。
本篇結論:
自動修正事故不能只看 Python 表面,要一路追到 ORM 最後交給資料庫的條件。
下一篇(Day 19)替工具說句公道話,然後把帳算清楚:工具沒有壞,它只是把通用規則套到了不通用的語意上。