SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法
一段查詢送進引擎,它檢查的是這段文字合不合法:名字解析得到、型別配得上、子句的組合合乎關聯代數的規則。通過之後它照著算,回一批列。「這批列是不是那個問題的答案」不在引擎檢查的範圍裡,因為問題不在查詢裡——查詢是問題的一種表達,而表達得對不對,只有提問的人知道。
本篇要用到這兩層的名字。合法指上一段那三項檢查都通過——判它不必讀任何一列資料,但要知道是哪一家引擎在判:各家收與不收的界線不同,標準禁止的寫法有的引擎照收,而那條界線劃在哪裡、各家怎麼劃,在 SQL.19 引擎寬鬆度與可攜性:各家對同一組寫法的差異、分級與處理時機;問對了指算出來的那份關係(欄位固定、由若干列構成的集合,查詢的結果就是一份關係)正好是那個問題的答案,這個詞是本篇為了指認「這批列是不是那個問題的答案」這一層而取的,SQL 的標準術語裡沒有它。
語意模型指這個語言規定的、由查詢的文字算出結果的那套規則,它的每一項規則都有一處與直覺相反(子句的先後、連接的左右、條件擺哪一邊、分組的鍵各是一例)。本篇處理直覺錯掉之後會發生什麼:引擎照樣算完、照樣回一批結果,而那批結果不帶任何出錯的訊號。
正解與錯的寫法:「不等於五」「漏掉星等」與「NOT IN」
書店要找出沒有五星評價的訂單。共用資料裡訂單 101 有一則五星、訂單 102 沒有評價,所以答案是 102。
正確的寫法把星等的條件放進 ON,再挑配不到的那些(SQL.5 ON 與 WHERE:連接條件與篩選條件各自的職責 的「找沒配到的那些列:IS NULL 條件的歸屬」一節):
1SELECT 訂單.訂單編號 FROM 訂單
2LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號 AND 評價.星等 = 5
3WHERE 評價.評價編號 IS NULL;
4-- 102「不等於五」的錯法把「沒有五星」寫成「星等不等於五」。 其餘不動,只把星等條件挪進 WHERE 並改成不等於:
1... LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號
2WHERE 評價.星等 <> 5;
3-- 共用資料:0 列
4-- 101 唯一那則評價是五星,5 <> 5 判成假
5-- 102 沒有評價,外連接替它補一列、星等是 NULL,NULL <> 5 的真值是未知,WHERE 只留判成真的列
6-- 訂單 101 多一則三星之後:101這一段問的是「至少有一則非五星評價的訂單」,與「沒有五星評價的訂單」是兩個不同的問題。外連接替配不到評價的訂單補出來、評價欄位全是 NULL 的那一列,MySQL 手冊叫 NULL-complemented row,本篇寫成補出來的列;對補出來的列判不成真的篩選條件叫拒絕 NULL 的條件(null-rejected condition),評價.星等 <> 5 就是一例(NULL 參與比較時為什麼判成未知,在 SQL.6 連接之後的列數與空缺 的「NULL 的比較結果與 IS NULL」一節)。問錯了問題,加上拒絕 NULL 的條件把補出來的列全數濾掉,兩件疊在一起,所以這段查詢回哪些列由資料決定:訂單 101 多一則三星之後,正解仍然是 102,而它交出了恰好相反的那一列。
補一個 IS NULL 讓補出來的列留下也修不掉,它在兩批資料上一次對、一次錯:
1... LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號
2WHERE 評價.星等 <> 5 OR 評價.星等 IS NULL;
3-- 共用資料:102,看起來完全正確
4-- 訂單 101 多一則三星之後:101 與 102補上的只是補出來的列,問題仍然是「至少有一則非五星評價」。
「漏掉星等」的錯法把星等條件整個漏掉,而它在現有資料上回了正確答案。 這一種的星等條件哪裡都沒出現:
1SELECT 訂單.訂單編號 FROM 訂單
2LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號
3WHERE 評價.評價編號 IS NULL;
4-- 共用資料:102
5-- 訂單 102 多一則三星評價之後:0 列(正解仍然是 102)這一段問的是「沒有任何評價的訂單」。它與正解在共用資料上是同一批列,因為有評價的那張訂單剛好只有五星那一則;兩段查詢要到某張訂單的評價全部不是五星時才分開。
「NOT IN」的錯法用 NOT IN 表達否定的成員判斷。 星等條件回到子查詢裡,連接整個省掉:
1SELECT 訂單編號 FROM 訂單
2WHERE 訂單編號 NOT IN (SELECT 訂單編號 FROM 評價 WHERE 星等 = 5);
3-- 共用資料:102
4-- 評價表多一則訂單編號還沒填的五星評價之後:0 列
5-- 子查詢交出 101 與 NULL,NOT IN 對訂單 102 展開成 102 <> 101 AND 102 <> NULL
6-- 前一項為真、後一項的真值是未知,AND 的結果是未知,WHERE 不留
7
8-- 同一個問題寫成 NOT EXISTS,加了那則評價之後仍然回 102
9SELECT 訂單編號 FROM 訂單
10WHERE NOT EXISTS (SELECT 1 FROM 評價
11 WHERE 評價.訂單編號 = 訂單.訂單編號 AND 星等 = 5);NOT IN 展開成一串「不等於」的 AND,子查詢只要交出一個 NULL,那一項對每一列都判成未知,整串因此不會是真(完整推導在 SQL.6 連接之後的列數與空缺:列數膨脹、外連接補的 NULL 與三值邏輯 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節)。NOT EXISTS 逐列問「有沒有一則配得上的五星評價」,訂單編號是 NULL 的那則評價配不上任何一張訂單,所以不影響結果。
答案錯掉的成因分類與引擎不報錯的原因
從要問的那一題到算出來的那批列,中間經過三個環節:提問的人把問題寫成查詢文字、語意模型規定這段文字算出什麼、引擎照它的實作去算。答案錯掉的成因按斷在哪一個環節分類:斷在問題寫成文字的,本篇叫問錯了問題;寫的人對某條規則的預期與語意模型的規定不同,叫誤解語意模型;引擎的行為偏離標準,叫引擎寬鬆;另有一類不斷在任何環節,而是要問的那一題本身還沒成形,叫問題還沒決定。
「不等於五」與「漏掉星等」問錯了問題。「不等於五」問的是「至少有一則非五星評價」,「漏掉星等」問的是「有沒有任何評價」——兩段查詢都精確地回答了它們自己問的那一題,而那一題不是要問的那一題。問錯了問題與模型的規則無關:換引擎、加約束都碰不到它,因為那段文字沒有任何一處是錯的;靜態分析判得出「不等於五」,憑的是它同時誤解了語意模型(見〈「問對了沒有」的判定:靜態分析、約束與反例資料〉)。問題與查詢文字之間還有反方向的落差,SQL.20 關鍵字的宣告與引擎的行為:宣告落空的形態與查證 叫它宣告落空:查詢的答案是對的,錯的是文字對下一個讀它的人說的話,例如 LEFT 宣告了一個從未發生的情況。問錯了問題的落差在問題與文字之間,宣告落空在文字與行為之間。
「NOT IN」的錯法誤解語意模型。NOT IN 的展開方式由模型定死,而寫的人預期它表現得像「不在這個集合裡」。誤解語意模型修得掉,改對之後永遠對。分頁是誤解語意模型的另一個實例:同分的列排在哪裡模型沒有規定,而逐頁取的人預期它固定,SQL.12 分頁的排序鍵與接續標記 寫它怎麼變成重複與遺漏。
「不等於五」的錯法同時屬於問錯了問題與誤解語意模型——它問的那個問題不是要問的,而它的星等條件又是拒絕 NULL 的條件、把補出來的列全數濾掉——所以它回的列隨資料變動,這是它比「漏掉星等」與「NOT IN」難診斷的原因。
引擎寬鬆的實例不在這三種錯法裡。引擎接受了標準禁止的寫法並自己補一個值:分組之後把非分組欄放進 SELECT,實例住在 SQL.2 子句的求值順序 與 SQL.9 分組鍵的選擇:鍵與一組所代表的對象,換一家引擎就浮現。SQL.2 除了求值順序,也寫了哪些違反求值順序的寫法會被引擎當場報錯、哪些會被照收——同一個放錯邊的條件在一家報錯、在另一家回一份錯的清單。問題還沒決定的實例是「所有訂單都被評價過的顧客」:它在一張訂單都沒下過的人身上為真(邏輯裡叫空真,vacuous truth),而提問的人多半還沒想過那算不算。問題還沒決定連換誰來寫都不會浮現,因為要問的那一題還沒成形。
這些成因有一個共通的地方:引擎回給寫查詢的人的只有兩樣,錯誤訊息與算出來的那批列,而這些錯誤在兩樣裡都不現形。引擎保證的是兩件事:這段文字合法,而算出來的關係符合語意模型。文字合法與關係符合語意模型這兩項都不決定答案對不對,而答案對不對也不影響這兩項,所以錯誤沒有任何一條經由引擎回報的管道。 三段查詢一則錯誤訊息都沒有,沒有型別衝突,沒有一列長得奇怪。
回來的那批列本身也看不出答案錯了,而看不出來的情形不只一種。「漏掉星等」的錯法在原本那批資料上回了正確答案,看不出問題;「不等於五」與「NOT IN」兩種回零列,而零列既可能是「確實沒有」也可能是條件整段失效——分不出這兩者,正是 SQL.8 IN、EXISTS 與 JOIN 把否定式一律交給 NOT EXISTS 的理由。結果的形狀正確也不構成保證。把訂單接上評價再加總金額:
1SELECT SUM(訂單.金額) FROM 訂單
2LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號;
3-- 共用資料:800(101 是 300、102 是 500)
4-- 替 101 再加一則評價之後:1100
5-- 101 配到兩則評價,連接後那張訂單變成兩列,300 算了兩次兩次回來的都是一列一欄的一個數字,形狀與正確答案沒有任何不同(連接為什麼會複製列,在 SQL.6 連接之後的列數與空缺:列數膨脹、外連接補的 NULL 與三值邏輯)。
「漏掉星等」的錯法另有一個性質:它在寫下的當時通得過任何檢驗。拿現有資料驗它,答案是對的;逐句讀它,每一個子句都符合語法與模型。
「問對了沒有」的判定:靜態分析、約束與反例資料
「問對了沒有」需要一個答案來比對,而那個答案不在查詢裡,也不在資料裡——它在提問的人手上。所以這一層交不出一條可以機械執行的判定——任何規則都得先知道要問的是什麼,才說得出這批列對不對。
「問對了沒有」這整條判定交不出去,而它底下有幾項較小的判斷交得出去。 一項判斷能不能交給機器,看判斷時要讀的資訊,機器在做這項判斷的那一刻讀不讀得到:引擎收到查詢的時候讀得到查詢的文字與表的結構,資料庫寫入一筆資料的時候讀得到那一筆資料與資料庫當下已有的列,而「要問的是什麼」只有提問的人知道。這些判斷因此按它們要讀什麼分開:只讀查詢文字與表結構的是靜態分析,只讀寫入那一刻資料庫裡的資料的寫成約束,要讀「要問的是什麼」的,機器能接手的只有比對——提問的人先造一筆反例資料、給出它對應的正確答案,機器負責比對查詢回的答案與它是否相同。
靜態分析只讀查詢的文字與表的結構,判斷時不需要任何一列資料,也不需要提問的人。 「不等於五」的錯法是它判得出來的例子:這段查詢寫了外連接,而 WHERE 裡的星等條件是拒絕 NULL 的條件,補出來的列一列都留不住,結果與內連接在任何資料上都相同。這個等價由三值邏輯決定、與資料裡有什麼無關,所以最佳化器規劃查詢時自己就會用到它,MySQL 手冊把這一步叫 outer join simplification。最佳化器做這個改寫時不產生任何警告,要讀執行計畫才看得到:
1-- 「不等於五」的錯法
2EXPLAIN
3SELECT 訂單.訂單編號 FROM 訂單
4LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號
5WHERE 評價.星等 <> 5;
6-- 星等條件拒絕 NULL:補出來的列星等是 NULL,NULL <> 5 判成未知,全數被濾掉
7-- PostgreSQL 16 印出 Hash Join:最佳化器已經把 LEFT JOIN 改成內連接
8-- MySQL 8 的 SHOW WARNINGS 印出改寫後的查詢,連接寫的是 join
9
10-- 正解
11EXPLAIN
12SELECT 訂單.訂單編號 FROM 訂單
13LEFT JOIN 評價 ON 評價.訂單編號 = 訂單.訂單編號 AND 評價.星等 = 5
14WHERE 評價.評價編號 IS NULL;
15-- 星等條件在 ON 裡;WHERE 的 評價編號 IS NULL 對補出來的列判成真,補出來的列留下
16-- PostgreSQL 16 印出 Hash Left Join:外連接保留下來外連接被最佳化器改成內連接,代表查詢裡的 LEFT 沒有作用,而寫下 LEFT 的人要的正是保留沒有評價的訂單。
「NOT IN」的錯法是另一個例子,靜態分析在它身上判出來的是風險而不是確定的錯。風險由表結構與查詢文字兩處合起來決定,兩處都不必讀任何一列資料:
1-- 表結構:評價表的訂單編號沒有宣告 NOT NULL,這一欄可能是 NULL
2CREATE TABLE 評價 (評價編號 INT, 訂單編號 INT, 星等 INT);
3
4-- 查詢文字:NOT IN 的子查詢讀的正是那一欄
5SELECT 訂單編號 FROM 訂單
6WHERE 訂單編號 NOT IN (SELECT 訂單編號 FROM 評價 WHERE 星等 = 5);
7-- 子查詢只要交出一個 NULL,NOT IN 對每一列都判不成真,整段回零列
8-- 現有資料裡沒有訂單編號未填的評價,所以這段此刻回的仍是 102靜態分析判不出「漏掉星等」:那段查詢與正解是同一種寫法,只差 ON 少了星等的條件,而查詢文字與表結構裡沒有任何一處說那個條件該在。
約束只讀寫入那一刻資料庫裡的資料,所以資料庫在寫入的那一刻就判得完。 約束是寫進資料庫、由它在每次寫入時檢查的規則,每一種讀的範圍由種類固定:NOT NULL 與 CHECK 只讀正要寫入的那一筆,UNIQUE 讀同一欄已有的值,外鍵讀它指向的那一列。NOT NULL 寫下「這一欄不會缺」,外鍵寫下「這個編號一定指得到一列」。讀的範圍超出這幾種的規則(「評價要在出貨之後」要讀另一張表的出貨日)同樣在寫入那一刻判得完,而要改用觸發器或應用層檢查。「NOT IN」的錯法要評價表裡出現一則訂單編號未填的五星評價才會回零列,而評價表的訂單編號那一欄若帶著 NOT NULL,那則評價一開始就寫不進來——這是「否定式一律用 NOT EXISTS」那條規則的豁免條件,不是對 NOT IN 展開方式那個誤解的修法。「不等於五」與「漏掉星等」寫不成約束:兩者錯在問錯了問題,而判斷一個問題有沒有問對要讀「要問的是什麼」,那不是任何一筆資料的性質。SQL.18 外鍵與參照完整性:宣告、生效與查詢得到的保證 拿參照完整性把約束走完一輪——外鍵擋下哪些寫入、宣告與生效在哪幾個位置分開,以及內連接掉列與 NOT IN 回零列這兩種錯各由哪一條約束管掉;五種約束各保證什麼在 Constraint(約束)。
要讀「要問的是什麼」的判斷交不出去,而提問的人的判斷可以記成一筆反例資料。 反例資料是一筆讓正解與某個錯的寫法回不同答案的資料,名字取自邏輯裡的反例(counterexample):兩段查詢在某一筆資料上回的答案不同,就證明兩者不等價。上面三種錯法各有一筆——「不等於五」要訂單 101 再多一則非五星評價,「漏掉星等」要訂單 102 多一則非五星評價,「NOT IN」要一則訂單編號未填的五星評價。
反例資料從表的結構與查詢的條件推得出來,不必先知道哪一種寫法是錯的。 測試領域把這個做法叫等價類劃分(equivalence partitioning):把輸入按程式對它的行為分類,每一類至少放一筆。落在這段查詢上,等價類是一張訂單配到評價的方式——一則都配不到、配到不只一則、配到的全部不是五星——再加上欄位是空值。沒有宣告約束的表全部允許這些等價類,而寫查詢的人容易當成它們不會發生;正解與錯的寫法對這些等價類的假設不同,所以兩者的答案就在這些等價類上分開。
一份把每個等價類各放進一次的資料,足以讓上面三種寫法各自交出不同的答案:
1-- 從建好的空表開始;101、102 與 9001 同共用資料
2INSERT INTO 訂單 VALUES (101,1,'2026-03-02',300), (102,1,'2026-03-09',500),
3 (103,2,'2026-03-10',200), (104,2,'2026-03-11',400),
4 (105,3,'2026-03-12',600);
5INSERT INTO 評價 VALUES
6 (9001,101,5), (9002,101,3), -- 101:配到不只一則,其中一則五星
7 (9003,103,3), -- 103:配到的全部不是五星
8 (9004,104,5), -- 104:只配到一則五星
9 (9005,105,4), (9006,105,2), -- 105:配到不只一則,全部不是五星
10 (9007,NULL,5); -- 訂單編號是空值的五星評價
11-- 102 一則評價都配不到
12-- 正解:102、103、105
13-- 「不等於五」:101、103、105、105(105 配到兩則非五星評價,出現兩次)
14-- 「漏掉星等」:102
15-- 「NOT IN」:0 列提問的人要出的仍然只有一件事:這五張訂單裡哪幾張是答案。造得出反例資料,代表提問的人已經把「要問的是什麼」說到分得開兩個問題的程度;造不出來,代表兩段查詢在這張表結構允許的每一種資料上都回同樣的答案,兩種寫法等價、選哪一種都對。反例資料與「問對了沒有」交不出去並不牴觸:反例資料與它對應的正確答案仍然由提問的人給出,交給機器的只有比對這一步。
反例資料與約束改變的對象不同。 約束改變的是資料能長成什麼樣,不改變查詢——加上 NOT NULL 之後「NOT IN」的錯法不再回零列,是因為觸發它的那則評價寫不進來,而那段查詢照樣合法、照樣是同一個誤解,同一個人在下一個可為空的欄位上會再寫一次 NOT IN。反例資料不改變資料庫允許什麼資料,提問的人的判斷也仍然由他給出,改變的是那個判斷的保存形式:反例資料當輸入、他給的答案當預期值,合起來就是一則測試案例,往後每次改動那段查詢都能再跑一次,而讀者多半已經有跑測試案例的工具。反例資料只證偽、不證成——「漏掉星等」的錯法在原本那批資料上通過了一切檢驗,而通過從來不是對的證明。反例資料的射程另有一道邊界:它由提問的人依他當時對問題的理解寫下,所以它分得出「想的對而寫錯了」,分不出「想的就不對」。
反例資料與它的預期答案在測試領域有對應的名字,而測試那邊分得比本篇細:預期值從哪裡取得叫 test oracle,那張卡列了規格、參照實作、人的判斷與現狀四種來源,以及各自抓不到的那些;「寫斷言的人與寫實作的人共用同一份誤解」叫 test provenance;而「造不出反例資料」在 Mutation Testing 裡叫 equivalent mutant。本篇只講這三個概念落在 SQL 查詢上的樣子,三張卡各自涵蓋本篇沒有處理的情境。
正確性之外:查詢的代價與由引擎、資料庫決定的事
本篇之外還有一條與正確性互不覆蓋的軸:同一段查詢要花多久。一段查詢問對了沒有,與它要花多久,各自的判斷標準管不到對方,而代價那一層有一個機械的做法——向引擎要執行計畫。SQL.17 查詢的代價 量的是一道重複值的題目:加一個索引,最慢的那一種寫法就掉到與最快的同一個量級。
本篇處理的錯誤都發生在查詢的文字與提問的人之間:引擎照語意模型把結果算對了,而那不是要問的那一題。一段查詢的結果還受幾件不寫在查詢文字裡的事影響,它們由收下查詢的引擎與資料庫決定。同一段查詢合不合法,由執行它的那一家引擎判定,而標準禁止的寫法有的引擎照收、有的報錯,SQL.19 引擎寬鬆度與可攜性:各家對同一組寫法的差異、分級與處理時機 逐個寫法比較各家收不收、收了之後回什麼。查詢裡的表名與欄名送進引擎之後,引擎會照自己的規則處理大小寫與引號之後再去找對應的表與欄,SQL.14 識別字的規則:各家的大小寫摺疊、引號與保留字 寫各家的大小寫規則、引號的作用與保留字。執行查詢的帳號被授予哪些權限、最佳化器選了哪一個執行計畫、表上宣告了哪些約束、字串照哪一套 collation 比較,也各自影響同一段查詢回什麼,分別在 SQL.16 SQL 的權限模型:角色、GRANT 的授權單位與最小權限、SQL.1 宣告式的紅利與代價、SQL.18 外鍵與參照完整性:宣告、生效與查詢得到的保證 與 SQL.15 字串比較與 collation:相等、排序、LIKE 與索引可用性。