SQL.20 關鍵字的宣告與引擎的行為:宣告落空的形態與查證
一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列;人讀的是宣告——寫的人選了 LEFT 而不選 INNER,選了 CROSS 而不選 JOIN ... ON,每一個選擇都在說明他預期資料長什麼樣。引擎執行行為,而它從不檢查宣告有沒有兌現。
宣告與行為分岔的時候,查詢照常回正確答案,測試照常通過,執行計畫上也看不出異常。付出代價的是下一個改它的人:他讀到的是宣告,於是他手上的前提從一開始就是錯的。宣告與行為之外,還有一種落差在查詢與要問的問題之間:查詢在語法上完全合法、引擎照實執行,而它回答的不是要問的那一題,那一種在 SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法;同一段文字換一家引擎會做什麼,是跨引擎的那一種,在 SQL.19 引擎寬鬆度與可攜性:各家對同一組寫法的差異、分級與處理時機。本篇的宣告落空時答案仍然正確,錯的是文字對下一個讀它的人說的話。
本篇的查詢跑在共用的範例資料庫上:三位顧客佳穎、宗翰、雅文,而只有佳穎下過兩張訂單(101 與 102,金額 300 與 500)。讀這一篇需要先有三件事:LEFT 保護了哪些列在 SQL.4 鏈式連接的左運算元,它與條件位置的互動在 SQL.5 ON 與 WHERE:連接條件與篩選條件各自的職責,連接怎麼把一列變成多列在 SQL.6 連接之後的列數與空缺。那三篇答的是「這個關鍵字做什麼」,這一篇不重寫那些機制,它問的是同一段查詢對下一個讀它的人說了什麼。
各個關鍵字的宣告與落空的形態
LEFT JOIN 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它,等於宣告他預期有配不到的列,而且那些列對答案有意義。
CROSS JOIN 說的是「我要每一種配對」。DISTINCT 說的是「這一組值本身會重複,而重複對我沒有意義」。COALESCE 說的是「這裡會有空值」。表的別名也是一種宣告:FROM 訂單 AS 早 說的是「這一次出現代表較早的那張單」。這些宣告是免費的,寫下去沒有人會查證。
落空的樣子不只一種。宣告可能被同一段查詢的別的部分撤銷(LEFT 加上一個問右表的 WHERE)、可能描述一個引擎並沒有執行的動作(CROSS 加等值條件)、可能描述一個從一開始就不存在的情況(配對已有保證時的 LEFT),也可能指向錯的成因而答案仍然正確(DISTINCT 收掉連接製造的重複),還可能名字與它代表的那一次出現對調(叫「早」的別名拿到後來那張單,條件跟著對調,結果不變,這個例子在 SQL.7 表的出現與別名:表名的指稱、別名的作用範圍與自連接)。這幾種落空在現有資料上都回正確答案,代價都落在下一個照著宣告動手的人身上:DISTINCT 指向錯成因,他多要一個欄位就回出錯的結果;被撤銷的 LEFT 讓他改了 SELECT 之後仍然少了沒有訂單的顧客,而原因在他沒有動的 WHERE;其餘幾種連錯的結果都不會出現,只讓他下錯判斷。
被 WHERE 撤銷的 LEFT JOIN
書店想找出下過大單的顧客。查詢寫成這樣:
1SELECT 顧客.姓名
2FROM 顧客 LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3WHERE 訂單.金額 > 200;
4-- 佳穎、佳穎
5
6-- 同一段把 LEFT JOIN 換成 JOIN,其餘不動
7SELECT 顧客.姓名
8FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
9WHERE 訂單.金額 > 200;
10-- 佳穎、佳穎LEFT 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 WHERE 問了一個右表欄位,宗翰與雅文那兩列右邊全是 NULL,NULL > 200 答不出真,於是被篩掉,外連接與內連接兩段因此回同樣兩列。
機制在 SQL.5 ON 與 WHERE:連接條件與篩選條件各自的職責:條件放 ON 參與配對,放 WHERE 篩成品,而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——這段查詢的行為是內連接,而它的文字說的是外連接。
下一個維護的人讀到 LEFT,會以為沒下單的顧客在結果裡。他想加一個「顯示零元」的欄位:
1SELECT 顧客.姓名, COALESCE(訂單.金額, 0) AS 金額 -- 他加的欄位:沒有訂單的顧客顯示 0
2FROM 顧客 LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3WHERE 訂單.金額 > 200; -- 他沒有動的那一行
4-- 佳穎 300
5-- 佳穎 500跑出來仍然只有佳穎,而他找不出原因——因為問題在他沒有動的那一行。
CROSS JOIN 加等值條件與內連接的計畫
要比較同一批資料的兩列時,常見的寫法是把表跟自己配對再篩:
1SELECT 今天.訂單編號
2FROM 訂單 昨天 CROSS JOIN 訂單 今天
3WHERE 今天.顧客編號 = 昨天.顧客編號 AND 今天.金額 > 昨天.金額;CROSS JOIN 宣告「我要每一種配對」,讀起來像是先造出所有配對再挑。而等值條件寫進去之後,最佳化器產出的計畫與內連接的計畫一模一樣。改寫成 JOIN ... ON 的版本把同一個條件搬進 ON:
1SELECT 今天.訂單編號
2FROM 訂單 昨天 JOIN 訂單 今天
3 ON 今天.顧客編號 = 昨天.顧客編號 AND 今天.金額 > 昨天.金額;
4-- 102(CROSS JOIN 那一段也回 102)同一組資料上兩種寫法的計畫逐字相同:
1-- CROSS JOIN 加 WHERE 等值(SQLite 3.51,共用的範例資料庫,沒有另外建索引)
2|--SCAN 昨天
3|--BLOOM FILTER ON 今天 (顧客編號=?)
4`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)
5
6-- JOIN ... ON 同一個條件
7|--SCAN 昨天
8|--BLOOM FILTER ON 今天 (顧客編號=?)
9`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)AUTOMATIC 表示這個索引是引擎為了這一次查詢臨時建的——訂單表上沒有 顧客編號 的索引,而它判斷建一個比逐列掃描划算。兩段查詢連這個決定都一樣。
CROSS JOIN 在代數上確實對應笛卡兒積,而那張卡寫明展開由條件決定:條件把兩邊綁起來時全部組合一次都沒有被造出來。
CROSS JOIN 這個宣告的誤導方向與 LEFT 相反。LEFT 那一節是宣告承諾了引擎沒做的事,這一節是宣告讓讀的人誤判了代價——看到 CROSS 就以為它是列數的平方,於是花力氣改寫一段本來就是內連接的查詢。兩段文字比不出快慢,快慢要向引擎要計畫——SQL.17 查詢的代價 在有無索引的兩種狀態下各量一次;而在這裡要先分清楚:CROSS JOIN 加等值條件與 JOIN ... ON 是同一段查詢的兩種寫法,換掉它省下的是讀的人的疑慮,不是執行時間。
配對已有保證時的 LEFT JOIN
每一張訂單都帶著一個有效的顧客編號時,這樣寫也會過:
1SELECT 訂單.訂單編號, 顧客.姓名
2FROM 訂單 LEFT JOIN 顧客 ON 顧客.顧客編號 = 訂單.顧客編號;LEFT 宣告「訂單可能找不到顧客」。而兩列訂單都配到了,這個宣告一次都沒有生效過:
1SELECT count(*), count(顧客.姓名) -- count(*) 數列數,count(欄位) 只數那一欄不是 NULL 的列
2FROM 訂單 LEFT JOIN 顧客 ON 顧客.顧客編號 = 訂單.顧客編號;
3-- 2 2:沒有任何一列是 LEFT 補出來的它的代價要等到有人相信它才出現。下一個人想查有沒有孤兒訂單(指不到任何顧客的訂單),順手接上這段查詢:
1SELECT 訂單.訂單編號
2FROM 訂單 LEFT JOIN 顧客 ON 顧客.顧客編號 = 訂單.顧客編號
3WHERE 顧客.姓名 IS NULL; -- 配不到顧客的訂單,姓名會是 LEFT 補上的 NULL
4-- 零列拿到零列,於是回報「資料乾淨」。零列在這裡有兩種來源:確實沒有孤兒訂單,或者這個宣告本來就沒有內容。這段查詢分不出兩者。
真正保證配對的是約束——外鍵存在時每一張訂單都指得到顧客,那時 LEFT 描述的是一個資料庫已經排除掉的情況。宣告與約束衝突時,讀的人多半相信宣告,因為它就在眼前,而約束在另一個檔案裡。
收掉連接重複的 DISTINCT
顧客與訂單連起來之後,佳穎出現兩次,因為她有兩張訂單。要一份「下過單的顧客」名單,加 DISTINCT 就對了:
1SELECT DISTINCT 姓名 FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;
2-- 佳穎DISTINCT 宣告「姓名這一欄有重複值」。而顧客表裡佳穎只有一列——重複是連接製造出來的(SQL.6 連接之後的列數與空缺:列數膨脹、外連接補的 NULL 與三值邏輯)。宣告指向了錯的成因,答案卻是對的。
這種宣告落空的代價由下一次修改觸發,而觸發它的動作很小——多要一個欄位就夠:
1SELECT DISTINCT 姓名, 金額 FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;
2-- 佳穎 300
3-- 佳穎 500DISTINCT 作用在整列上,兩列的金額不同,於是它一列都收不掉。上一個人的修法在這裡失效,而失效的理由要回到「重複從哪裡來」這個問題——那個問題當初被 DISTINCT 跳過了。
查證宣告的動作:把關鍵字換成最弱的版本再跑一次
文字本身看不出宣告有沒有兌現——LEFT 寫在那裡,而它保護下來的列可能一列都不剩。差別要現形,得讓 LEFT 這類關鍵字消失一次:把它換成最弱的版本再跑一次,結果變不變就是它有沒有在做事。 這個動作在軟體測試裡叫 mutation testing:在現有資料上結果不變,是那張卡說的突變「存活」;它是不是等價突變(改寫之後行為在任何資料上都相同),要看造不造得出一筆讓改寫前後回不同答案的反例資料(SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法 用的名字與造法)。
| 宣告 | 換成 | 結果不變表示 |
|---|---|---|
LEFT JOIN / RIGHT JOIN | JOIN | 在現有資料上行為是內連接 |
CROSS JOIN 加 WHERE 等值 | JOIN ... ON 同一個條件 | 這段本來就是內連接(限等值) |
DISTINCT | 拿掉 | 這一組值原本就各不相同 |
COALESCE(欄位, 預設值) | 直接用該欄位 | 這一欄在現有資料上沒有空值 |
結果變了的時候,換掉關鍵字這個動作只證明被換掉的那個關鍵字在做事,還沒證明宣告是對的。DISTINCT 那一格就是這種:拿掉之後結果會變,而變的理由是連接把列複製了,與「這一欄有重複值」無關。所以結果變了之後還要問:變化的成因與宣告說的是不是同一件事。
配對已有保證時的 LEFT(〈配對已有保證時的 LEFT JOIN〉那一節)是換掉關鍵字再跑一次這個動作測不出來的:配對由外鍵保證的時候,LEFT 與 JOIN 在現有資料上結果相同,而換掉之後仍然看不出宣告當初有沒有意義。那一種要去查約束,判斷標準是資料庫有沒有替這個配對背書——而約束寫進 schema 也不一定生效,各家在哪一步把寫下與生效分開,在 SQL.18 外鍵與參照完整性:宣告、生效與查詢得到的保證。
對讀別人查詢的人來說,這個寫下與生效是否一致的差別回答的是「我可以相信這段文字說的話嗎」——而那個問題在讀的當下沒有別的入口。文字該先為哪一個讀者而寫——人還是引擎——是 SQL.21 可讀性與執行代價:兩者一致與分岔的情形 的題目。