前面學了 JOIN 做水平合併,也學了 UNION、INTERSECT、EXCEPT 做垂直整併。
但這些都是單獨的小練習,實際工作中的需求通常更複雜——你可能需要同時用到子查詢、分組計算、多次合併,才能把老闆要的資料拼出來。
這篇就用一個補習班系統的例子,來示範怎麼把這些技巧組合起來解決真實的問題。
先來看補習班系統的資料結構
還記得之前在做資料架構的時候,我們幫補習班設計過一套資料表嗎?
現在就用這套表來練習。
據點表:
人員表:
課程表:
教室表:
授課班級表:
授課班級表記錄的是一個「開班」的概念。
例如班級ID 1 這筆資料,代表的是「王老師在 A 教室開了一班三年級自然課」。
同一門課程可能有多個授課班級。
像三年級自然課(課程ID 1)就有兩個授課班級:一個是王老師在 A 教室開的班,另一個是李老師在 C 教室開的班。
選課表:
選課表記錄的是「哪個學生選了哪個授課班級」。
為什麼是選授課班級,不是選課程?
因為同一門課程可能有好幾個班。
學生選課的時候,不是只說「我要上三年級自然課」,而是要選「王老師在 A 教室開的那一班」還是「李老師在 C 教室開的那一班」。
所以選課表連結的是學生和授課班級,不是學生和課程。
這幾張表彼此勾連,組成了補習班的核心系統。
用子查詢找出還沒滿班的授課班級
老闆說:「三年級自然課現在很夯,很多班都滿了。幫我挑出三年級自然課還沒滿班的授課班級,還有它是在哪個分校上課。」
先拆解問題
聽到這個需求,腦中要先想清楚幾件事:
- 我要挑的是「授課班級」,所以授課班級是主角
- 我要知道這個授課班級的課程是不是三年級自然科
- 我要知道這個課程的學生上限是多少
- 我要知道這個授課班級目前選課的人數有多少
- 比對選課人數和學生上限,就知道有沒有滿班
這可以分成三個步驟:
- 先挑出三年級自然科的授課班級,順便拿到學生上限
- 算出每個授課班級的選課人數
- 把兩個結果合併,篩選出還沒滿班的
第一步:挑出三年級自然科的授課班級
授課班級是主角,所以授課班級一定放在左邊。
授課班級表裡只有課程ID,沒有課程的詳細資料,所以要 JOIN 課程表。
SELECT 授課班級.班級ID, 課程.學生上限
FROM 授課班級
LEFT JOIN 課程 ON 授課班級.課程ID = 課程.課程ID
WHERE 課程.年級 = 3 AND 課程.科目 = '自然'這樣就挑出三年級自然科的授課班級,還有它的學生上限。
結果:
班級ID 1 和 2 都是三年級自然課(課程ID 1),所以被挑出來。
班級ID 3 是三年級數學課,不符合條件,沒有出現在結果裡。
第二步:算出每個授課班級的選課人數
要算人數,就要把授課班級和選課表合併,然後分組計算。
SELECT 授課班級.班級ID, COUNT(*) AS 學生人數
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID我們一步一步來看這個查詢做了什麼。
首先是 LEFT JOIN:
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID結果:
班級ID 1 有兩筆選課紀錄,所以產生兩列。
班級ID 3 沒有人選,但因為是 LEFT JOIN,還是會出現在結果裡,選課的欄位都是 NULL。
接著用 GROUP BY 分組:
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID最後用 COUNT 算每組有幾列,就是學生人數:
等等,班級ID 3 明明沒有人選,為什麼學生人數是 1?
因為 COUNT() 是算「這組有幾列」,班級ID 3 雖然選課欄位是 NULL,但還是有一列資料,所以 COUNT() 算出來是 1。
如果要正確計算,應該用 COUNT(選課.選課ID),這樣 NULL 就不會被算進去:
SELECT 授課班級.班級ID, COUNT(選課.選課ID) AS 學生人數
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID結果:
這樣班級ID 3 的學生人數就正確顯示為 0 了。
第三步:合併兩個結果,篩選還沒滿班的
現在有兩個結果:
- 結果一:三年級自然科的授課班級,還有學生上限
- 結果二:每個授課班級的學生人數
把這兩個結果合併,比對學生人數和學生上限,就知道哪些還沒滿班。
先看整體結構:
SELECT 三年級自然科班級.班級ID
FROM (
-- 第一步:挑出三年級自然科的授課班級
) AS 三年級自然科班級
LEFT JOIN (
-- 第二步:算出每個授課班級的選課人數
) AS 班級學生人數
ON 三年級自然科班級.班級ID = 班級學生人數.班級ID
WHERE 學生人數 < 學生上限圓括號裡面的就是子查詢,分別對應第一步和第二步。
把子查詢的內容填進去:
SELECT 三年級自然科班級.班級ID
FROM (
SELECT 授課班級.班級ID, 課程.學生上限
FROM 授課班級
LEFT JOIN 課程 ON 授課班級.課程ID = 課程.課程ID
WHERE 課程.年級 = 3 AND 課程.科目 = '自然'
) AS 三年級自然科班級
LEFT JOIN (
SELECT 授課班級.班級ID, COUNT(選課.選課ID) AS 學生人數
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID
) AS 班級學生人數
ON 三年級自然科班級.班級ID = 班級學生人數.班級ID
WHERE 學生人數 < 學生上限這裡用了子查詢:圓括號裡面的 SELECT 就是一個子查詢,它會先執行完,產生一個暫時的表,再拿來做合併。
為什麼從「三年級自然科班級」開始 JOIN?
注意看,我們是用「三年級自然科班級」去 LEFT JOIN「班級學生人數」。
「三年級自然科班級」只有班級ID 1 和 2(三年級自然科的授課班級)。
「班級學生人數」有班級ID 1、2、3(所有授課班級的學生人數)。
從「三年級自然科班級」開始 LEFT JOIN,出來的結果就只會有班級ID 1 和 2。
班級ID 3 雖然在「班級學生人數」裡面,但因為「三年級自然科班級」裡沒有它,所以不會出現在結果裡。
這樣就不用在最外層再加一個 WHERE 課程.年級 = 3 AND 課程.科目 = '自然' 來篩選了。
用 USING 簡化合併條件
上面的合併條件是這樣寫的:
ON 三年級自然科班級.班級ID = 班級學生人數.班級ID兩邊的欄位名稱都是「班級ID」,可以用 USING 來簡化:
LEFT JOIN 班級學生人數 USING (班級ID)USING 的意思是:左右兩張表都有這個欄位,用它來做合併。
這樣寫起來更簡潔,也更容易讀懂。
完整的 SQL
SELECT 三年級自然科班級.班級ID
FROM (
SELECT 授課班級.班級ID, 課程.學生上限
FROM 授課班級
LEFT JOIN 課程 ON 授課班級.課程ID = 課程.課程ID
WHERE 課程.年級 = 3 AND 課程.科目 = '自然'
) AS 三年級自然科班級
LEFT JOIN (
SELECT 授課班級.班級ID, COUNT(選課.選課ID) AS 學生人數
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID
) AS 班級學生人數
USING (班級ID)
WHERE 學生人數 < 學生上限用 INTERSECT 找出需要換教室的授課班級
老闆又說:「有些授課班級選課人數還不到 10 個人,卻用了一間大教室(人數上限超過 20 人)。幫我把這些授課班級挑出來,我要讓它們換教室。」
換個思路:用交集來解
這個需求其實是兩個條件的交集:
- 選課人數不到 10 人的授課班級
- 教室人數上限超過 20 人的授課班級
同時符合這兩個條件的授課班級,就是我們要找的。
如果用 JOIN 的方式,你要先算人數,再合併教室表,再篩選,會有點複雜。
用 INTERSECT 就簡單多了:分別查出兩組結果,再取交集。
第一步:找出選課人數不到 10 人的授課班級
一步一步來看怎麼寫這個查詢。
首先是 LEFT JOIN:
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID結果:
接著用 GROUP BY 分組:
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID最後用 HAVING 篩選出 COUNT(選課.選課ID) < 10 的:
SELECT 授課班級.班級ID
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID
HAVING COUNT(選課.選課ID) < 10結果:
三個班級的選課人數都不到 10 人,所以全部都符合條件。
這裡用 HAVING 而不是 WHERE,因為我們是根據分組計算的結果來篩選。
WHERE 是在分組之前篩選,HAVING 是在分組之後篩選。
第二步:找出教室人數上限超過 20 人的授課班級
一樣一步一步來。
首先是 LEFT JOIN:
FROM 授課班級
LEFT JOIN 教室 ON 授課班級.教室ID = 教室.教室ID結果:
接著用 WHERE 篩選人數上限超過 20 的:
SELECT 授課班級.班級ID
FROM 授課班級
LEFT JOIN 教室 ON 授課班級.教室ID = 教室.教室ID
WHERE 教室.人數上限 > 20結果:
班級ID 1 的教室是 A 教室(人數上限 30),班級ID 2 的教室是 C 教室(人數上限 25),都超過 20。
班級ID 3 的教室是 B 教室(人數上限 15),不超過 20,所以被篩掉了。
第三步:取交集
SELECT 授課班級.班級ID
FROM 授課班級
LEFT JOIN 選課 ON 授課班級.班級ID = 選課.班級ID
GROUP BY 授課班級.班級ID
HAVING COUNT(選課.選課ID) < 10
INTERSECT
SELECT 授課班級.班級ID
FROM 授課班級
LEFT JOIN 教室 ON 授課班級.教室ID = 教室.教室ID
WHERE 教室.人數上限 > 20INTERSECT 會找出同時出現在上下兩個結果中的班級ID。
這些就是「選課人數不到 10 人,但用了大教室」的授課班級。
結果:
第一步的結果是班級ID 1、2、3,第二步的結果是班級ID 1、2。
交集就是 1 和 2。
巢狀結構很複雜怎麼辦?
你可能已經發現,當子查詢越來越多,整個 SQL 會變得很難讀。
像任務一的完整 SQL,光是看那一層一層的括號,就讓人頭暈。
如果你覺得這個巢狀結構很不舒服,想要有一個乾淨易讀的版本,下一篇會教你怎麼用 CTE(Common Table Expression)來整理這些子查詢。
小結
這篇學到了三件事:
從主角開始思考:
拿到需求,先想清楚「我要挑的是什麼」。
那個東西就是主角,一定放在 FROM 的最左邊。
拆解問題,分步驟處理:
複雜的需求不要想一次解決。
先拆成幾個小步驟,各自寫成子查詢,最後再合併起來。
善用 INTERSECT 簡化邏輯:
當需求是「同時符合多個條件」的時候,用 INTERSECT 取交集,比用一堆 JOIN 和 WHERE 更直覺。