Set Operations, NULL & Aggregate Functions
UNION / INTERSECT / EXCEPT · NULL กับตรรกะ 3 ค่า · multiset · COUNT SUM AVG MIN MAX · GROUP BY และ HAVING
วิดีโอที่อาจารย์ให้ดูก่อนเรียนบทนี้:
manager_id เป็น NULL)ส่วนแบบฝึกหัด Section A เพิ่มอีก 2 แถวเพื่อเปิดเผยด้าน NULL: แผนก Legal ที่ไม่มีพนักงาน และ Nina ที่อยู่แผนก Research ซึ่งไม่มีในตาราง departments
7.1Set Operations
Set operation ใช้รวมหรือเปรียบเทียบผลลัพธ์ของ SELECT ตั้งแต่สองชุดขึ้นไป โดยมองผลลัพธ์เหล่านั้นเป็นเซตของแถว แล้วดำเนินการตามทฤษฎีเซต
| Operation | คืนอะไร | Relational algebra |
|---|---|---|
| UNION | รวมผลลัพธ์ของทั้งสอง ลบแถวที่ซ้ำกันออก | ∪ |
| INTERSECT | แถวที่ปรากฏในทั้งสองผลลัพธ์ | ∩ |
| EXCEPT (MINUS) | แถวจาก query แรกที่ไม่ปรากฏในผลลัพธ์ของ query ที่สอง | − |
ตัวอย่างข้างล่างใช้ตาราง section = “วิชาที่เปิดสอนจริงเทอมไหนบ้าง” ตามข้อมูลนี้
(schema เต็ม: section(course_id, sec_id, semester, year, building, room_number) — ที่นี่แสดงแค่ 3 คอลัมน์ที่ใช้):
| course_id | semester | year |
|---|---|---|
| CS-101 | Fall | 2009 |
| CS-347 | Fall | 2009 |
| CS-101 | Spring | 2010 |
| CS-315 | Spring | 2010 |
Fall = {CS-101, CS-347} · Spring = {CS-101, CS-315} → UNION ได้ {CS-101, CS-347, CS-315} · INTERSECT ได้ {CS-101} · EXCEPT (Fall ลบ Spring) ได้ {CS-347}
-- UNION: วิชาที่เปิด Fall 2009 หรือ Spring 2010 → {CS-101, CS-347, CS-315} = 3 แถว
(SELECT course_id FROM section WHERE semester='Fall' AND year=2009)
UNION
(SELECT course_id FROM section WHERE semester='Spring' AND year=2010);
-- INTERSECT: วิชาที่เปิดทั้งสองภาค → {CS-101} = 1 แถว
(SELECT course_id FROM section WHERE semester='Fall' AND year=2009)
INTERSECT
(SELECT course_id FROM section WHERE semester='Spring' AND year=2010);
-- EXCEPT: เปิด Fall แต่ไม่เปิด Spring → {CS-347} = 1 แถว
(SELECT course_id FROM section WHERE semester='Fall' AND year=2009)
EXCEPT
(SELECT course_id FROM section WHERE semester='Spring' AND year=2010);
SELECT course_id, semester, year ทั้งสองฝั่ง UNION จะได้ 4 แถว ไม่ใช่ 3
เพราะ CS-101 ของ Fall 2009 กับ CS-101 ของ Spring 2010 เป็นคนละแถว (semester ต่างกัน) จึงไม่ถูกยุบUNION ยุบแถวที่เหมือนกัน “ทั้งแถว” เท่านั้น — จะให้ยุบตามวิชา ก็ต้อง project เหลือ course_id ก่อน (หลักเดียวกับ Π ก่อน − ในบทที่ 3)
• UNION ลบแถวซ้ำ · ถ้าอยากเก็บแถวซ้ำไว้ใช้ UNION ALL (เร็วกว่าด้วย เพราะไม่ต้องเรียงเพื่อหาแถวซ้ำ)
• MySQL รองรับ INTERSECT / EXCEPT ตั้งแต่ 8.0.31 ขึ้นไป · ถ้าเวอร์ชันเก่ากว่าให้จำลองด้วย
IN / NOT IN หรือ join
จำลอง INTERSECT และ EXCEPT ใน MySQL เวอร์ชันเก่า
-- INTERSECT →
SELECT DISTINCT course_id FROM section WHERE semester='Fall'
AND course_id IN (SELECT course_id FROM section WHERE semester='Spring');
-- EXCEPT →
SELECT DISTINCT course_id FROM section WHERE semester='Fall'
AND course_id NOT IN (SELECT course_id FROM section WHERE semester='Spring');
(subquery = query ที่ซ้อนอยู่ในวงเล็บข้างใน query อีกอัน — รันข้างในก่อนได้ชุดค่าออกมา แล้วข้างนอกค่อยเทียบ ·
เรียนเต็ม ๆ ที่ บทที่ 8 · NOT EXISTS อยู่ที่ 8.2c)
ระวัง NOT IN กับ NULL! ถ้า subquery คืนค่า NULL มาด้วยแม้แถวเดียว
NOT IN จะให้ผลเป็น UNKNOWN ทุกแถว → query ไม่คืนอะไรเลย · ทางแก้: ใส่ WHERE col IS NOT NULL ใน subquery หรือใช้ NOT EXISTS แทน
7.2NULL และตรรกะ 3 ค่า ออกสอบแน่
tuple สามารถมีค่า null ในบาง attribute ได้ · null หมายถึงค่าที่ไม่ทราบ หรือค่าที่ไม่มีอยู่
กฎที่ต้องจำ
- ผลของนิพจน์คณิตศาสตร์ใด ๆ ที่เกี่ยวข้องกับ null คือ null →
5 + NULL = NULL - การเปรียบเทียบใด ๆ กับ null ให้ผลเป็น unknown →
5 < NULL,NULL <> NULL,NULL = NULLล้วนได้ unknown - ใช้
IS NULL/IS NOT NULLในการตรวจเท่านั้น - ผลของ predicate ใน WHERE clause ถูกถือว่าเป็น false ถ้ามันประเมินได้เป็น unknown → แถวนั้นไม่ถูกเลือก
"P is unknown"ประเมินเป็น true ถ้า predicate P ให้ผลเป็น unknown
IS UNKNOWN ของจริง
ปกติ WHERE จะทิ้งแถวที่ผลเป็น unknown · แต่บางครั้งเราอยากได้แถวพวกนั้นมาดูว่าข้อมูลตรงไหนขาดหาย
— IS UNKNOWN คือวิธี “จับ” มันโดยตรง
-- หาแถวที่ "ตอบไม่ได้" ว่าหัวหน้าคือคนที่ id มากกว่า 1 หรือเปล่า
-- (= แถวที่ manager_id เป็น NULL → ในชุดข้อมูล HR คือ John)
SELECT * FROM employees WHERE (manager_id > 1) IS UNKNOWN;
| manager_id ของแถวนั้น | manager_id > 1 ได้ผลว่า | WHERE ธรรมดาเก็บไหม | IS UNKNOWN เก็บไหม |
|---|---|---|---|
| 4 (Lisa) | TRUE | เก็บ | ไม่เก็บ |
| 1 (Jane) | FALSE | ไม่เก็บ | ไม่เก็บ |
| NULL (John) | UNKNOWN | ไม่เก็บ | เก็บ |
WHERE manager_id IS NULL ในกรณีง่าย ๆ แบบนี้ —
แต่พอ predicate ซับซ้อนขึ้น (เช่น (a > b AND c < d) IS UNKNOWN) IS UNKNOWN จะเขียนสั้นกว่ามาก
เพราะไม่ต้องไล่เช็ค IS NULL ทีละคอลัมน์มีคู่แฝดคือ
IS TRUE, IS FALSE, IS NOT UNKNOWN ใช้หลักเดียวกัน ·
MySQL ยังมี <=> (null-safe equal) ที่ทำให้ NULL <=> NULL เป็น TRUE ต่างจาก = ธรรมดา
SELECT * FROM employees WHERE manager_id <> 1;
คนที่หายไปคือ John (manager_id = NULL) — เพราะ NULL <> 1 ให้ผลเป็น UNKNOWN ไม่ใช่ TRUE
WHERE จึงไม่เก็บแถวนั้น ทั้งที่ตามความหมายภาษาคนแล้ว John ก็ “ไม่ได้อยู่ใต้พนักงานหมายเลข 1” เหมือนกันวิธีแก้:
SELECT * FROM employees WHERE manager_id <> 1 OR manager_id IS NULL;
ตารางความจริง 3 ค่า
- unknown OR true = true
- unknown OR false = unknown
- unknown OR unknown = unknown
- true AND unknown = unknown
- false AND unknown = false
- unknown AND unknown = unknown
- NOT unknown = unknown
สังเกตว่า false AND unknown = false และ true OR unknown = true — สองกรณีนี้ตอบได้โดยไม่ต้องรู้ค่าที่หายไป
SELECT first_name, manager_id, manager_id + 100 AS manager_bonus_code FROM employees;พนักงานที่ไม่มีหัวหน้า (manager_id = NULL) จะได้ manager_bonus_code = NULL ไม่ใช่ 100
เพราะเลขคณิตใด ๆ กับ NULL ให้ผลเป็น NULL เสมอ · ถ้าอยากได้ 100 ต้องใช้
COALESCE(manager_id, 0) + 100
COALESCE(NULL, 0) -- ได้ 0 (ตัวแรกเป็น NULL ข้ามไปตัวถัดไป)
COALESCE(5, 0) -- ได้ 5 (ตัวแรกไม่ใช่ NULL ใช้เลย)
COALESCE(NULL, NULL, 'ไม่มีข้อมูล') -- ได้ 'ไม่มีข้อมูล'
ใช้บ่อยมากตอนแทนค่า NULL ด้วยค่า default เพื่อให้คำนวณหรือแสดงผลได้:
SELECT first_name,
COALESCE(manager_id, 0) + 100 AS manager_bonus_code,
COALESCE(department, 'ยังไม่ระบุ') AS dept
FROM employees;
ระวัง: ค่าที่เอามาแทนควรเป็นชนิดเดียวกัน — COALESCE(salary, 'ไม่มี')
มาตรฐาน SQL และ PostgreSQL จะ error ส่วน MySQL แปลงทั้งคอลัมน์เป็นข้อความให้เงียบ ๆ
ซึ่งอันตรายกว่า เพราะเอาผลไปบวกลบต่อไม่ได้แล้วโดยไม่มีใครรู้MySQL ยังมี
IFNULL(a, b) ซึ่งเป็น COALESCE แบบรับได้แค่ 2 ตัว · Oracle ใช้ NVL() · COALESCE เป็นมาตรฐาน SQL ใช้ได้ทุกระบบ
7.3Multiset (Bag) และ Aggregate Functions
Multiset (bag) คือกลุ่มของสมาชิกที่ ลำดับไม่สำคัญ (เหมือนเซต) แต่ มีสมาชิกซ้ำได้ (ต่างจากเซต)
เช่น {1, 2, 2, 3, 4, 4, 4} เป็น multiset ที่ถูกต้อง
- ตารางในฐานข้อมูลเป็น multiset โดยธรรมชาติ เพราะมีแถวซ้ำได้ (ถ้าไม่มี constraint อย่าง PRIMARY KEY)
- ผลลัพธ์ของ SELECT ก็เป็น multiset โดยค่าเริ่มต้น
- Aggregate function สัมพันธ์กับแนวคิด multiset โดยตรง — รับกลุ่มของค่าเข้าไป คืนค่าเดียวออกมา
| ฟังก์ชัน | คืนอะไร |
|---|---|
| AVG | ค่าเฉลี่ย |
| MIN | ค่าน้อยที่สุด |
| MAX | ค่ามากที่สุด |
| SUM | ผลรวม |
| COUNT | จำนวนค่า |
COUNT — สามแบบที่ต่างกัน
| รูปแบบ | นับอะไร |
|---|---|
| COUNT(*) | นับทุกแถว ต่อให้คอลัมน์ไหนเป็น NULL ก็ยังนับ · ตัวเดียวที่ไม่ตัดแถว NULL ทิ้ง |
| COUNT(column) | เฉพาะแถวที่คอลัมน์นั้นไม่เป็น NULL |
| COUNT(DISTINCT column) | จำนวนค่าที่ไม่ซ้ำและไม่เป็น NULL — มองคอลัมน์เป็นเซตแทนที่จะเป็น multiset |
COUNT(*) จะเมินเฉย (ignore) แถวที่มี NULL ในคอลัมน์ที่นำมาคำนวณSELECT SUM(salary) FROM instructor → ข้ามค่า null ทิ้งถ้าไม่มีค่าที่ไม่ใช่ null เลย ผลลัพธ์คือ null
ถ้าคอลัมน์มีแต่ค่า null ล้วน ๆ:
COUNT → 0 · ตัวอื่นทั้งหมด (SUM, AVG, MIN, MAX) → NULL
SELECT COUNT(*) AS all_employees,
COUNT(manager_id) AS with_manager
FROM employees;
-- all_employees = 8 (นับทุกแถว)
-- with_manager = 7 (John มี manager_id = NULL จึงไม่ถูกนับ)
SUM(salary) = 600 · COUNT(salary) = 3 · COUNT(*) = 5AVG(salary) = 600 / 3 = 200 — ไม่ใช่ 600/5 = 120เพราะ AVG หารด้วยจำนวนค่าที่ไม่เป็น null เท่านั้น — ข้อสอบชอบเอาจุดนี้มาถาม
7.4GROUP BY และ HAVING
SELECT dept, COUNT(*) FROM e GROUP BY dept — dept อยู่ใน GROUP BY แล้วSELECT name, dept, COUNT(*) FROM e GROUP BY dept — name ไม่อยู่ใน GROUP BY ทั้งที่ไม่ได้อยู่ใน aggregate(MySQL บางโหมดยอมให้ผ่าน แต่ค่าที่ได้จะไม่แน่นอน — และในข้อสอบถือว่าผิด)
WHERE vs HAVING
| WHERE | HAVING | |
|---|---|---|
| ทำงานเมื่อไหร่ | ก่อน GROUP BY | หลัง GROUP BY |
| กรองอะไร | แถวแต่ละแถว (individual rows) | กลุ่ม (groups) |
| ใช้ aggregate ได้ไหม | ไม่ได้ | ได้ |
HAVING ใช้ระบุเงื่อนไขการค้นหาสำหรับกลุ่มหรือ aggregate ถูกใช้ควบคู่กับ GROUP BY เพื่อกรองผลของ aggregate function
7.5ตะลุยโจทย์ Aggregate — ข้อ 14–20
14. รายงานจำนวนพนักงาน ค่าเฉลี่ย ต่ำสุด สูงสุด และผลรวมเงินเดือน
SELECT COUNT(*) AS total_employees,
AVG(salary) AS avg_salary,
MIN(salary) AS min_salary,
MAX(salary) AS max_salary,
SUM(salary) AS total_salary_expense
FROM employees;
เมื่อไม่มี GROUP BY ทั้งตารางถูกมองเป็นกลุ่มเดียว จึงได้ผลลัพธ์แถวเดียว
15. นับว่าพนักงานกระจายอยู่กี่แผนก
SELECT COUNT(DISTINCT department) AS department_count FROM employees;
ถ้าใช้ COUNT(department) เฉย ๆ จะได้ 8 (จำนวนแถว) ไม่ใช่ 4 (จำนวนแผนกที่ต่างกัน)
17–18. GROUP BY และ GROUP BY + ORDER BY
-- 17. จำนวนพนักงานในแต่ละแผนก
SELECT department, COUNT(*) AS num_employees
FROM employees
GROUP BY department;
-- 18. เงินเดือนเฉลี่ยต่อแผนก เรียงจากมากไปน้อย
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
ORDER BY avg_salary DESC;
ORDER BY ใช้ alias avg_salary ได้ เพราะ SELECT ทำงานก่อน ORDER BY ในลำดับเชิงตรรกะ ·
แต่ WHERE ใช้ alias ไม่ได้ เพราะ WHERE ทำงานก่อน SELECT
19–20. HAVING
-- 19. แผนกที่มีพนักงานมากกว่า 1 คน
SELECT department, COUNT(*) AS num_employees
FROM employees
GROUP BY department
HAVING COUNT(*) > 1;
-- 20. แผนกที่เงินเดือนเฉลี่ยเกิน 70,000
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 70000;
เขียนแบบนี้ไม่ได้: WHERE COUNT(*) > 1 หรือ WHERE AVG(salary) > 70000
เพราะตอนที่ WHERE ทำงาน ยังไม่มีการจัดกลุ่มเกิดขึ้น จึงไม่มี “จำนวนสมาชิกในกลุ่ม” ให้นับ
เปรียบเทียบให้ชัด:
WHERE salary > 70000 = “เอาเฉพาะพนักงานที่เงินเดือนเกิน 70,000 มาคิด”
HAVING AVG(salary) > 70000 = “เอาเฉพาะแผนกที่เงินเดือนเฉลี่ยเกิน 70,000”
สองอันนี้ให้ผลลัพธ์ต่างกันโดยสิ้นเชิง
7.6โจทย์ Section A จาก Part 2&3 — เน้น NULL
ข้อ A4–A6 ใช้ตาราง projects และ employee_projects ซึ่งยังไม่ได้แสดงในบทนี้ —
เปิดดูโครงสร้างและข้อมูลทั้งสองตารางที่บทที่ 6
projects(project_id, project_name, start_date, department_id) ·
employee_projects(employee_id, project_id, role) = junction table แบบ M:N
ใครไม่มีโปรเจกต์เลย: ในข้อมูลชุดฐาน พนักงาน id 1–8 มีโปรเจกต์กันครบทุกคน ข้อ A6 จึงจะได้ผลลัพธ์ว่าง —
ต้องใช้ชุดที่เพิ่ม Nina (id 9) เข้ามา ซึ่งไม่มีแถวใน employee_projects เลย → คำตอบคือ Nina
A1–A3 · IS NULL, RIGHT JOIN, FULL OUTER JOIN
-- A1. พนักงานที่ไม่มีหัวหน้า
SELECT * FROM employees WHERE manager_id IS NULL;
-- A2. ทุกแผนก รวมแผนกที่ไม่มีพนักงาน (Legal)
SELECT d.department_name, d.location, e.first_name, e.last_name
FROM employees e
RIGHT JOIN departments d ON e.department = d.department_name;
-- A3. พนักงานทุกคนและแผนกทุกแผนก (จำลอง FULL OUTER JOIN)
SELECT e.first_name, e.department, d.department_name, d.location
FROM employees e LEFT JOIN departments d ON e.department = d.department_name
UNION
SELECT e.first_name, e.department, d.department_name, d.location
FROM employees e RIGHT JOIN departments d ON e.department = d.department_name;
A3 เปิดเผยทั้งสองด้าน: Nina (แผนก Research ที่ไม่มีในตาราง departments) จะมี department_name = NULL ·
Legal (แผนกที่ไม่มีพนักงาน) จะมี first_name = NULL
A4–A6 · UNION, INTERSECT, EXCEPT
-- A4. UNION — รวมเหตุการณ์ "รับพนักงานเข้า" กับ "เริ่มโปรเจกต์"
SELECT hire_date AS event_date, CONCAT(first_name,' ',last_name) AS label,
'hire' AS event_type
FROM employees
UNION
SELECT start_date, project_name, 'project_start'
FROM projects
ORDER BY event_date;
-- A5. INTERSECT — คนที่เป็นทั้งหัวหน้า และลงมือทำโปรเจกต์เอง
SELECT DISTINCT e.employee_id FROM employees e
WHERE EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.employee_id)
INTERSECT
SELECT DISTINCT employee_id FROM employee_projects;
-- A6. EXCEPT — พนักงานที่ไม่ได้ถูกมอบหมายโปรเจกต์ใดเลย
SELECT employee_id FROM employees
EXCEPT
SELECT employee_id FROM employee_projects;
EXISTS แปลว่า “subquery ข้างในคืนแถวมาอย่างน้อย 1 แถวไหม”
และ r ข้างในอ้างถึง e ของ query ข้างนอก เรียกว่า correlated subquery
(อธิบายเต็มที่บทที่ 8.2c)ถ้ายังไม่อยากใช้ EXISTS เขียนด้วยของบทที่ 7 ล้วน ๆ ก็ได้ ให้ผลเดียวกัน:
SELECT employee_id FROM employees
WHERE employee_id IN (SELECT manager_id FROM employees WHERE manager_id IS NOT NULL)
INTERSECT
SELECT DISTINCT employee_id FROM employee_projects;A4 ต้องระวัง: UNION ต้องการจำนวนคอลัมน์เท่ากันและชนิดเข้ากันได้ จึงต้องจัดให้ทั้งสองฝั่งมี 3 คอลัมน์ที่เทียบกันได้ ·
การเติมคอลัมน์ค่าคงที่ ('hire' / 'project_start') ช่วยให้ดูออกว่าแต่ละแถวมาจากไหน
ORDER BY วางไว้ท้ายสุดครั้งเดียว — ใช้กับผลลัพธ์รวม ไม่ใช่กับแต่ละ SELECT
7.7สรุปบทนี้
- Set operations — UNION (ลบซ้ำ) · INTERSECT · EXCEPT · ต้อง arity เท่ากันและชนิดเข้ากันได้
- NULL = ไม่ทราบค่า หรือไม่มีอยู่ · เลขคณิตกับ NULL → NULL · เปรียบเทียบกับ NULL → unknown · WHERE เก็บเฉพาะ TRUE
- Multiset = ลำดับไม่สำคัญ แต่มีซ้ำได้ · ตารางและผลลัพธ์ SELECT เป็น multiset โดยธรรมชาติ
- Aggregate ทุกตัวยกเว้น COUNT(*) เมิน NULL · มีแต่ NULL → COUNT ได้ 0 ตัวอื่นได้ NULL
- WHERE กรองแถวก่อนจัดกลุ่ม · HAVING กรองกลุ่มหลังจัดกลุ่ม · HAVING ใช้ aggregate ได้ WHERE ใช้ไม่ได้
bonus มี 5 แถว ค่าเป็น NULL ทั้งหมด · SELECT COUNT(bonus), SUM(bonus), AVG(bonus) FROM t; ได้อะไรCOUNT(*) จะได้ 5 เพราะนับทุกแถวไม่สนใจ NULLSELECT * FROM employees WHERE manager_id <> 1; คืนกี่แถว ถ้ามี 3 คนที่ manager_id = 1NULL <> 1 ให้ผลเป็น UNKNOWN และ WHERE เก็บเฉพาะ TRUE เท่านั้น