EN813304 · Midterm
บทที่ 7 / 12 · sql-part2.pptx (ครึ่งหลัง) · สัปดาห์ที่ 6

Set Operations, NULL & Aggregate Functions

UNION / INTERSECT / EXCEPT · NULL กับตรรกะ 3 ค่า · multiset · COUNT SUM AVG MIN MAX · GROUP BY และ HAVING

SQL 60%กับดักเยอะ
หัวข้อในบทนี้

วิดีโอที่อาจารย์ให้ดูก่อนเรียนบทนี้:

Introduction to SQL (Part 2) — ตอนที่ 2
ข้อมูลที่ใช้ในบทนี้ โจทย์ตั้งแต่หัวข้อ 7.5 เป็นต้นไปใช้ฐานข้อมูล HR ชุดเดียวกับบทที่ 6 — เปิดตาราง employees / departments / projects / employee_projects ที่นี่ (8 พนักงาน · 4 แผนก · John เป็นคนเดียวที่ 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_idsemesteryear
CS-101Fall2009
CS-347Fall2009
CS-101Spring2010
CS-315Spring2010

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 คอลัมน์เดียว ถ้าเขียน SELECT course_id, semester, year ทั้งสองฝั่ง UNION จะได้ 4 แถว ไม่ใช่ 3 เพราะ CS-101 ของ Fall 2009 กับ CS-101 ของ Spring 2010 เป็นคนละแถว (semester ต่างกัน) จึงไม่ถูกยุบ
UNION ยุบแถวที่เหมือนกัน “ทั้งแถว” เท่านั้น — จะให้ยุบตามวิชา ก็ต้อง project เหลือ course_id ก่อน (หลักเดียวกับ Π ก่อน − ในบทที่ 3)
ข้อกำหนดและข้อควรระวัง • SELECT ทั้งสองชุดต้องมี จำนวนคอลัมน์เท่ากัน และ ชนิดข้อมูลเข้ากันได้
• 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 หมายถึงค่าที่ไม่ทราบ หรือค่าที่ไม่มีอยู่

กฎที่ต้องจำ

  1. ผลของนิพจน์คณิตศาสตร์ใด ๆ ที่เกี่ยวข้องกับ null คือ null → 5 + NULL = NULL
  2. การเปรียบเทียบใด ๆ กับ null ให้ผลเป็น unknown → 5 < NULL, NULL <> NULL, NULL = NULL ล้วนได้ unknown
  3. ใช้ IS NULL / IS NOT NULL ในการตรวจเท่านั้น
  4. ผลของ predicate ใน WHERE clause ถูกถือว่าเป็น false ถ้ามันประเมินได้เป็น unknown → แถวนั้นไม่ถูกเลือก
  5. "P is unknown" ประเมินเป็น true ถ้า predicate P ให้ผลเป็น unknown
กฎข้อ 5 ใช้ยังไง — 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 ต่างจาก = ธรรมดา
กับดักคลาสสิก — โจทย์ A7 ในแบบฝึกหัด “แสดงพนักงานทุกคนที่ไม่ได้อยู่ใต้บังคับบัญชาของพนักงานหมายเลข 1”
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 ค่า

OR
  • unknown OR true = true
  • unknown OR false = unknown
  • unknown OR unknown = unknown
AND
  • true AND unknown = unknown
  • false AND unknown = false
  • unknown AND unknown = unknown
NOT
  • NOT unknown = unknown

สังเกตว่า false AND unknown = false และ true OR unknown = true — สองกรณีนี้ตอบได้โดยไม่ต้องรู้ค่าที่หายไป

โจทย์ A8 — NULL ในการคำนวณ 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 (อ่านว่า โค-อะ-เลส แปลว่า “หลอมรวม”)
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 ที่ถูกต้อง

ฟังก์ชันคืนอะไร
AVGค่าเฉลี่ย
MINค่าน้อยที่สุด
MAXค่ามากที่สุด
SUMผลรวม
COUNTจำนวนค่า

COUNT — สามแบบที่ต่างกัน

รูปแบบนับอะไร
COUNT(*)นับทุกแถว ต่อให้คอลัมน์ไหนเป็น NULL ก็ยังนับ · ตัวเดียวที่ไม่ตัดแถว NULL ทิ้ง
COUNT(column)เฉพาะแถวที่คอลัมน์นั้นไม่เป็น NULL
COUNT(DISTINCT column)จำนวนค่าที่ไม่ซ้ำและไม่เป็น NULL — มองคอลัมน์เป็นเซตแทนที่จะเป็น multiset
NULL กับ aggregate — กฎที่ต้องท่อง Aggregate ทุกตัวยกเว้น COUNT(*) จะเมินเฉย (ignore) แถวที่มี NULL ในคอลัมน์ที่นำมาคำนวณ

SELECT SUM(salary) FROM instructor → ข้ามค่า null ทิ้ง
ถ้าไม่มีค่าที่ไม่ใช่ null เลย ผลลัพธ์คือ null

ถ้าคอลัมน์มีแต่ค่า null ล้วน ๆ: COUNT → 0 · ตัวอื่นทั้งหมด (SUM, AVG, MIN, MAX) → NULL
โจทย์ข้อ 16 — พิสูจน์ความต่างของ COUNT
SELECT COUNT(*)          AS all_employees,
       COUNT(manager_id) AS with_manager
FROM   employees;
-- all_employees = 8 (นับทุกแถว)
-- with_manager  = 7 (John มี manager_id = NULL จึงไม่ถูกนับ)
ทำไม AVG ถึงต่างจาก SUM/COUNT(*) ถ้ามีเงินเดือน 5 แถว: 100, 200, NULL, 300, NULL
SUM(salary) = 600 · COUNT(salary) = 3 · COUNT(*) = 5
AVG(salary) = 600 / 3 = 200 — ไม่ใช่ 600/5 = 120
เพราะ AVG หารด้วยจำนวนค่าที่ไม่เป็น null เท่านั้น — ข้อสอบชอบเอาจุดนี้มาถาม

7.4GROUP BY และ HAVING

กฎเหล็กของ GROUP BY attribute ทุกตัวใน SELECT clause ที่อยู่นอก aggregate function ต้องปรากฏใน GROUP BY list

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

WHEREHAVING
ทำงานเมื่อไหร่ก่อน GROUP BYหลัง GROUP BY
กรองอะไรแถวแต่ละแถว (individual rows)กลุ่ม (groups)
ใช้ aggregate ได้ไหมไม่ได้ได้

HAVING ใช้ระบุเงื่อนไขการค้นหาสำหรับกลุ่มหรือ aggregate ถูกใช้ควบคู่กับ GROUP BY เพื่อกรองผลของ aggregate function

ลำดับการทำงานเชิงตรรกะของ SQL — ต้องท่องได้
FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
นี่คือเหตุผลที่ WHERE ใช้ aggregate ไม่ได้ (ตอนนั้นยังไม่มีกลุ่ม) และเป็นเหตุผลที่ ORDER BY ใช้ alias ที่ตั้งใน SELECT ได้ (เพราะ SELECT ทำงานก่อน ORDER BY)

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;
A5 ยืมของบทที่ 8 มาใช้ — 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สรุปบทนี้

คอลัมน์ bonus มี 5 แถว ค่าเป็น NULL ทั้งหมด · SELECT COUNT(bonus), SUM(bonus), AVG(bonus) FROM t; ได้อะไร
COUNT คืน 0 เมื่อไม่มีค่าที่ไม่ใช่ null · aggregate ตัวอื่นคืน NULL · ถ้าเป็น COUNT(*) จะได้ 5 เพราะนับทุกแถวไม่สนใจ NULL
ข้อใดเขียนถูก
ข้อ 1 ผิดเพราะ WHERE ใช้ aggregate ไม่ได้ · ข้อ 3 ผิดเพราะ name อยู่นอก aggregate แต่ไม่อยู่ใน GROUP BY · ข้อ 4 ผิดเพราะใช้ HAVING โดยไม่มี GROUP BY (ทางเทคนิคทำได้กับทั้งตารางเป็นกลุ่มเดียว แต่โจทย์ต้องการต่อแผนก จึงไม่ตรงเจตนา)
ตาราง employees มี 8 แถว หนึ่งในนั้น manager_id = NULL · SELECT * FROM employees WHERE manager_id <> 1; คืนกี่แถว ถ้ามี 3 คนที่ manager_id = 1
8 − 3 (manager_id = 1) − 1 (NULL) = 4 แถว · แถวที่ manager_id เป็น NULL ถูกตัดทิ้ง เพราะ NULL <> 1 ให้ผลเป็น UNKNOWN และ WHERE เก็บเฉพาะ TRUE เท่านั้น
ระบบจำไว้ในเบราว์เซอร์นี้ จะได้รู้ว่าอ่านถึงไหนแล้ว