會員列表原本有三個人,接上訂單表後只剩一個。SQL明明寫了 LEFT JOIN,為什麼沒訂單的會員還是消失?先看右表條件寫在哪裡。若先接上所有訂單,再用 WHERE o.status = 'paid' 篩選,沒有相符右表的資料列會帶著NULL;這個條件不會把它們留下。
修法取決於列表想回答什麼。若只要列出付過款的人,篩選掉其他會員可能是正確的。若要保留全部會員,再顯示各人的已付款訂單,付款條件應參與連接。本文用三位會員、三張訂單把兩種結果攤開,並說明為何補一個 OR IS NULL 不一定能找回你以為遺失的人。
三位會員,先不急著改查詢
範例的左表 members 有Ada、Ben、Cara三人。右表 orders 裡,Ada有一張paid、一張pending;Ben只有一張pending;Cara沒有任何訂單。這組資料能區分「沒有訂單」與「有訂單,但沒有符合狀態的訂單」,兩者往往被混在一起。
-- members: id, name
-- 1, Ada
-- 2, Ben
-- 3, Cara
-- orders: id, member_id, status
-- 11, 1, paid
-- 12, 1, pending
-- 13, 2, pending
在自己的資料庫核對前,先選幾個已知情況的會員ID:有付款、有未付款、完全沒訂單。不要只拿一位交易正常的會員測試。右表可能還有退款、軟刪除與歷史狀態;若網站用的狀態值與範例不同,先查實際欄位定義,不直接複製 paid。
WHERE問的是接好以後要留下哪些列
SELECT m.id, m.name,
o.id AS order_id, o.status
FROM members AS m
LEFT JOIN orders AS o
ON o.member_id = m.id
WHERE o.status = 'paid'
ORDER BY m.id, o.id;
這段查詢只留下Ada的訂單11。Ben接到pending訂單,狀態不符而被篩掉。Cara沒有右表資料,接出的右表欄位是NULL;NULL與 'paid' 比較不能得到真,因此也不通過WHERE。這是查詢條件的結果,不是MySQL把Cara的資料刪掉,也不是LEFT JOIN關鍵字失效。
不要把這個現象擴寫成「LEFT JOIN後面永遠不能有WHERE」。對左表加 WHERE m.id > 0,仍可能保留所有符合左表條件的會員。對右表使用 IS NULL 也有找未匹配資料的用途。要判斷的是這一個條件會不會拒絕右表補出的NULL,而不是只看WHERE出現的位置。
ON決定哪些右表資料算相符
SELECT m.id, m.name,
o.id AS order_id, o.status
FROM members AS m
LEFT JOIN orders AS o
ON o.member_id = m.id
AND o.status = 'paid'
ORDER BY m.id, o.id;
這次Ada接到訂單11,Ben與Cara各保留一列,右表欄位都為NULL。Ben雖然有pending訂單,它不符合這次的連接條件,因此對「已付款訂單」這個右側結果來說,Ben沒有匹配列。Cara本來就沒訂單,也沒有匹配列;兩人的左表資料都仍被保留。
這個結果適合「全部會員+他們的已付款訂單」清單。但一位會員如果有三張已付款訂單,仍會產生三列,不會自動折成一個會員。把篩選條件移到ON只解決匹配與保留問題,沒有解決一對多關聯的重複列;若畫面一人一列,要另外設計彙總、最新一筆或存在條件。
為什麼OR IS NULL救不回Ben
SELECT m.id, m.name,
o.id AS order_id, o.status
FROM members AS m
LEFT JOIN orders AS o
ON o.member_id = m.id
WHERE o.status = 'paid'
OR o.id IS NULL
ORDER BY m.id, o.id;
這個版本留下Ada與Cara,但仍沒有Ben。原因在連接時就已分岔:Ben有pending訂單,所以LEFT JOIN產生的是有訂單ID的實際列,不會額外製造一列「Ben+NULL」。付款條件不符,訂單ID又不是NULL,整個WHERE條件仍為假。
因此,「沒匹配到任何訂單」與「匹配到訂單,但WHERE把全部訂單排除」不是同一種情況。如果讀者目標是保留每個會員,不能只在既有篩選後面加NULL例外就假設完成。回到需求,把需要限制的右表資料寫進ON,再以那個結果處理顯示。
數量彙總還有COUNT的差別
SELECT m.id,
COUNT(*) AS joined_rows,
COUNT(o.id) AS paid_orders
FROM members AS m
LEFT JOIN orders AS o
ON o.member_id = m.id
AND o.status = 'paid'
GROUP BY m.id
ORDER BY m.id;
範例三人的 joined_rows 都是1,paid_orders 則是1、0、0。COUNT(*)計算保留下來的列,包含LEFT JOIN補出的列;COUNT(o.id)只計算該欄不是NULL的值。此處訂單ID是右表主鍵,適合用來計算真正匹配到的訂單。若你改用可能為NULL的備註欄,就會把有訂單但沒備註的情況漏算。
分頁會員清單時,也要核對總筆數查詢是否與正文列表採相同的會員範圍。把加入訂單後的列數直接當會員數,可能令頁碼多出幾頁。不要只靠DISTINCT把畫面壓回一人一列,卻不理會排序、明細與付款數字是否還有明確意義。
接第三張表時,重新畫出保留範圍
若會員接訂單後,又把訂單接到付款紀錄,下一段連接仍可能改變結果。第二段如果使用INNER JOIN,沒有訂單或沒有付款紀錄的會員可能再次被排除。這時只檢查第一個LEFT JOIN的ON條件還不夠,要逐段列出哪些左側列應被保留,以及每段用哪個欄位判定匹配。
例如畫面只需顯示會員與已付款訂單,不一定需要再接一張只用來取得付款渠道的表。若渠道資料可缺,通常應把它當作可選明細;若渠道是顯示資格的一部分,才把它寫成篩選條件。先問資料缺少時畫面應出現什麼,再選連接方式,能避免一看到NULL就改成INNER JOIN。
合成案例中右表主鍵不為NULL,所以用訂單ID判斷是否匹配清楚可靠。不要改用一個允許空值的業務欄位判斷右表有沒有資料;真實訂單的備註可能就是NULL。對JOIN後的NULL,也要分清它來自未匹配的補值,還是來源列本身允許空值。
ORM產生的SQL也要讀到條件位置
如果網站使用Laravel等Query Builder,畫面上的鏈式呼叫不一定讓人一眼看出條件落在ON還是WHERE。把右側狀態放進join回呼,與在外層接一個where,可能生成不同查詢。先讀實際生成的SQL與綁定參數,再套回本文三組資料比較,不要憑方法名稱判定兩種寫法等價。
保留舊查詢結果與預期結果的小樣本,日後新增退款或取消狀態時再次跑同一組資料。測試要檢查具體會員ID與數量,僅斷言查詢沒有報錯,無法抓出Ben這類「合法SQL卻漏資料」的問題。樣本足夠小時,可以直接列明預期ID,讓維護者看出條件改動後哪一類會員消失。
改查詢前後,核對同一組會員
先保存原SQL、參數與樣本ID。修改後核對三類會員是否都按需求出現,再比較右表明細、NULL欄位與數量。這種查詢先用SELECT觀察即可,不需要為了測試連接去修改正式訂單狀態。遇到大量結果,先限制已知ID及日期範圍,避免在忙碌時段取回整個交易表。
多加右表條件時,把每個條件的角色講清楚。例如訂單狀態與訂單建立日期可以一起限制ON中的匹配;會員啟用狀態則通常屬於左表範圍。若網站要的是「有至少一張已付款訂單的會員」,可另考慮EXISTS表達存在需求,讓畫面的一人一列與查詢意圖一致。
索引只能影響查詢如何找到資料,不能把錯誤的保留條件改成正確。確定結果之後,再看 MySQL索引的使用方式;若對空值本身不熟悉,可先讀 NULL、空字串與0的差別。不要因為查詢變快就略過結果核對。
示例使用MySQL 8.4合成資料,三位會員與三張訂單;未連接正式會員或訂單系統。資料型別、實際狀態及一對多顯示方式需按自己的網站核對。

評論0