SQL Part 3 — Subqueries & Database Modification
subquery ใน WHERE / FROM / SELECT · IN · SOME · ALL · EXISTS · WITH · scalar subquery · INSERT UPDATE DELETE · CASE · COALESCE
วิดีโอที่อาจารย์ให้ดูก่อนเรียนบทนี้:
section, teaches, instructor, department, student, takes)
ใช้กับตัวอย่างที่ยกมาจากสไลด์/ตำรา — โครงสร้างเต็มอยู่ที่ บทที่ 5 หัวข้อ 5.32. ชุด HR (
employees, departments, projects, employee_projects)
ใช้กับโจทย์แบบฝึกหัด Section B และ C — เปิดดูข้อมูลจริงทุกตารางที่นี่Section B และ C ใช้ชุดที่เพิ่มสองแถว:
(5,'Legal','Seattle') = แผนกที่ไม่มีพนักงาน (ใช้ในข้อ B6) ·
(9,'Nina','Patel','Research',60000,'2023-06-01',NULL) = พนักงานที่ไม่มีโปรเจกต์ (ใช้ในข้อ B2)เตือนซ้ำ:
employees.department เก็บชื่อแผนกเป็นข้อความ (join กับ departments.department_name)
แต่ projects.department_id เก็บเลข id (join กับ departments.department_id) — โจทย์ C1 ใช้จุดนี้
8.1Nested Subqueries ภาพรวม
Subquery คือ query ที่ซ้อนอยู่ภายใน query อื่น ใช้ได้ใน 3 ที่:
ทดสอบเงื่อนไข — set membership (IN) · set comparison (SOME, ALL) · existence (EXISTS)
ใช้ผลลัพธ์ของ subquery เป็นตารางชั่วคราว (derived table) — ต้องตั้ง alias เสมอ
Scalar subquery — subquery ที่คืนค่าเดียว (1 แถว 1 คอลัมน์) มาใช้เป็นคอลัมน์หนึ่ง
นอกจากนี้ WITH clause ยังให้เรานิยามตารางชั่วคราวที่ใช้ได้เฉพาะใน query นั้น
8.2Subqueries ใน WHERE clause
Set membership — IN / NOT IN
-- วิชาที่เปิดสอนทั้ง Fall 2009 และ Spring 2010
SELECT DISTINCT course_id
FROM section
WHERE semester = 'Fall' AND year = 2009
AND course_id IN (SELECT course_id FROM section
WHERE semester = 'Spring' AND year = 2010);
-- วิชาที่เปิด Fall 2009 แต่ไม่เปิด Spring 2010
SELECT DISTINCT course_id
FROM section
WHERE semester = 'Fall' AND year = 2009
AND course_id NOT IN (SELECT course_id FROM section
WHERE semester = 'Spring' AND year = 2010);
-- จำนวนนักศึกษา (ไม่ซ้ำ) ที่เคยลง section ที่สอนโดยอาจารย์ ID 10101
SELECT COUNT(DISTINCT ID)
FROM takes
WHERE (course_id, sec_id, semester, year) IN
(SELECT course_id, sec_id, semester, year
FROM teaches WHERE teaches.ID = 10101);
IN แบบหลายคอลัมน์
สองตัวอย่างแรกเทียบ คอลัมน์เดียว กับลิสต์ค่าเดียว · ตัวอย่างที่สามเทียบ 4 คอลัมน์พร้อมกัน
เพราะ “กลุ่มเรียนหนึ่งกลุ่ม” ต้องใช้ 4 ค่าถึงจะระบุได้ (composite key ของ section)อ่านว่า: เอาชุดค่า
(course_id, sec_id, semester, year) ของแต่ละแถวใน takes
ไปหาว่า ตรงกับชุดใดชุดหนึ่งที่ subquery คืนมาไหม · ต้องตรงครบทั้ง 4 ตำแหน่งถึงจะนับว่าเจอ
-- สมมติ subquery คืน 2 แถวนี้ (วิชาที่อาจารย์ 10101 สอน)
('CS-101','1','Fall',2017)
('CS-347','1','Fall',2017)
-- แถว takes ที่ผ่าน:
('CS-101','1','Fall',2017) ตรงครบ 4 ตำแหน่ง
-- แถว takes ที่ไม่ผ่าน:
('CS-101','2','Fall',2017) sec_id ต่างกัน (คนละกลุ่มเรียน)
('CS-101','1','Fall',2018) คนละปี
ลำดับคอลัมน์ในวงเล็บซ้าย ต้องตรงกับลำดับใน SELECT ของ subquery เป๊ะ ๆ สลับกันไม่ได้ —
ถ้าเขียน (sec_id, course_id, …) ระบบจะเอา sec_id ไปเทียบกับ course_idทบทวนไวยากรณ์นี้แบบพื้นฐานได้ที่ บทที่ 5 หัวข้อ tuple comparison
x NOT IN (…, NULL) จะให้ผลเป็น UNKNOWN เสมอ
→ WHERE ไม่เก็บแถวไหนเลย → query คืนผลลัพธ์ว่าง ทั้งที่โค้ดดูถูกทุกอย่างวิธีแก้: เติม
WHERE col IS NOT NULL ใน subquery หรือใช้ NOT EXISTS แทน
Set comparison — SOME / ANY และ ALL
SELECT first_name, salary FROM employees
WHERE salary > SOME (SELECT salary FROM employees
WHERE department = 'Marketing');
จริงถ้าเงินเดือนมากกว่าอย่างน้อยหนึ่งคนในแผนก Marketing
≡ > MIN(เซตนั้น)
SELECT first_name, salary FROM employees
WHERE salary > ALL (SELECT salary FROM employees
WHERE department = 'Marketing');
จริงก็ต่อเมื่อเงินเดือนมากกว่าทุกคนในแผนก Marketing
≡ > MAX(เซตนั้น)
< ANY → < MAX · < ALL → < MIN
= ANY ≡ IN · <> ALL ≡ NOT IN
= ALL ถึง ไม่ เท่ากับ IN
| เขียนแบบ | แปลว่า | ผลเมื่อเซตคือ {65000, 62000} |
|---|---|---|
| x = ANY (…) | x เท่ากับอย่างน้อยหนึ่งค่าในเซต | จริงเมื่อ x = 65000 หรือ 62000 → เหมือน IN ทุกประการ |
| x = ALL (…) | x เท่ากับทุกค่าในเซตพร้อมกัน | เป็นเท็จเสมอ — ไม่มีเลขไหนที่เท่ากับ 65000 และ 62000 ในเวลาเดียวกัน |
= ALL จะเป็นจริงได้ก็ต่อเมื่อเซตนั้นมีค่าเดียวกันหมด (หรือมีแค่ค่าเดียว)
จึงแทบไม่มีใครใช้ในงานจริง — ถ้าเห็นในข้อสอบ ให้ระวังว่ามันเป็นตัวลวงกรณีขอบ: ถ้า subquery คืนศูนย์แถว →
= ALL เป็น TRUE (ไม่มีอะไรให้ขัดแย้ง)
ส่วน = ANY เป็น FALSE (ไม่มีอะไรให้ตรง) — ตรงข้ามกันพอดี
EXISTS และ Correlated Subquery
-- วิชาที่เปิดสอน Fall 2009 แต่ไม่เปิด Spring 2010 — เขียนด้วย EXISTS
SELECT course_id
FROM section AS S1
WHERE semester = 'Fall' AND year = 2009
AND NOT EXISTS (SELECT * FROM section AS S2
WHERE S2.semester = 'Spring' AND S2.year = 2010
AND S1.course_id = S2.course_id);
- Correlation name = ตัวแปร
S1ใน outer query - Correlated subquery = inner query ที่อ้างถึงตัวแปรจาก outer query
- เพราะ inner query ต้องใช้ค่าจากแต่ละแถวของ outer จึงถูกรันใหม่ทุกแถว — ช้ากว่า subquery ธรรมดา
-- โจทย์ข้อ 11: พนักงานที่เป็นหัวหน้า (มีอย่างน้อยหนึ่งคนรายงานตรง)
SELECT * FROM employees e
WHERE EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.employee_id);
-- โจทย์ B3: พนักงานที่เงินเดือนสูงกว่าค่าเฉลี่ยของแผนกตัวเอง
SELECT * FROM employees e
WHERE salary > (SELECT AVG(salary) FROM employees
WHERE department = e.department);
ทำไมนิยมเขียน SELECT 1 ใน EXISTS — เพราะ EXISTS สนใจแค่ว่า “มีแถวไหม” ไม่สนใจว่าค่าคืออะไร
การเขียน SELECT 1 จึงสื่อเจตนาชัดกว่า SELECT * (ประสิทธิภาพเท่ากัน optimizer ฉลาดพอ)
8.3Subqueries ใน FROM clause
-- เงินเดือนเฉลี่ยของแผนกที่มีค่าเฉลี่ยเกิน 70,000
SELECT dept_name, avg_salary
FROM (SELECT dept_name, AVG(salary) AS avg_salary
FROM instructor
GROUP BY dept_name) AS dept_avg
WHERE avg_salary > 70000;
Every derived table must have its own alias2. ต้องตั้งชื่อ (alias) ให้คอลัมน์ที่เป็น aggregate ไม่งั้นอ้างถึงจากข้างนอกไม่ได้
วิธีนี้เป็นทางเลือกแทน HAVING — ทั้ง HAVING AVG(salary) > 70000 และการซ้อน subquery ใน FROM ให้ผลเหมือนกัน
แต่แบบ FROM อ่านง่ายกว่าเมื่อเงื่อนไขซับซ้อนขึ้น
8.4WITH clause (Common Table Expression)
WITH clause ให้เรานิยาม relation ชั่วคราว ซึ่งใช้ได้เฉพาะภายใน query ที่ประกาศไว้เท่านั้น
พอ query จบ ตารางชั่วคราวนั้นก็หายไป — ไม่ได้ถูกเก็บลงดิสก์เหมือน CREATE TABLE
WITH ชื่อตารางชั่วคราว ( ชื่อคอลัมน์1, ชื่อคอลัมน์2 ) AS
( SELECT … ) ← ตัว query ที่สร้างตารางนั้น
SELECT … FROM ชื่อตารางชั่วคราว ← query หลักที่เอาไปใช้
วงเล็บหลังชื่อคืออะไร — เป็นการตั้งชื่อคอลัมน์ของตารางชั่วคราวตามลำดับ
(คอลัมน์ที่ 1 ที่ SELECT คืนมา ได้ชื่อแรกในวงเล็บ · คอลัมน์ที่ 2 ได้ชื่อที่สอง …)ทำไมต้องตั้ง — เพราะ
SELECT MAX(salary) คืนคอลัมน์ที่ยังไม่มีชื่อใช้งานได้
(ชื่อจริงคือ MAX(salary) ซึ่งมีวงเล็บ อ้างถึงยาก) การเขียน max_salary(value)
ทำให้อ้างถึงได้สั้น ๆ ว่า max_salary.valueละได้ไหม — ละได้ ถ้า SELECT ข้างในตั้ง alias ให้ทุกคอลัมน์อยู่แล้ว · สองแบบนี้ให้ผลเหมือนกัน:
WITH max_salary(value) AS (SELECT MAX(salary) FROM employees) -- ตั้งชื่อที่หัว
WITH max_salary AS (SELECT MAX(salary) AS value FROM employees) -- ตั้งชื่อข้างใน
ระวัง: ถ้าใส่วงเล็บ ต้องใส่ให้ครบทุกคอลัมน์ — จำนวนชื่อในวงเล็บต้องเท่ากับจำนวนคอลัมน์ที่ SELECT คืนมาพอดี
-- หาพนักงานที่ได้เงินเดือนสูงสุด
WITH max_salary(value) AS
(SELECT MAX(salary) FROM employees)
SELECT employee_id, first_name, salary
FROM employees, max_salary
WHERE employees.salary = max_salary.value;
-- โจทย์ B5: แผนกที่ค่าใช้จ่ายเงินเดือนรวมเกิน 150,000
WITH dept_total(dept_name, total_salary) AS
(SELECT department, SUM(salary) FROM employees GROUP BY department)
SELECT * FROM dept_total WHERE total_salary > 150000;
ข้อดีของ WITH: อ่านง่ายกว่าการซ้อน subquery หลายชั้น · ใช้ตารางชั่วคราวเดิมซ้ำได้หลายที่ใน query เดียว · แบ่งตรรกะซับซ้อนออกเป็นขั้นตอน
8.5Scalar Subquery ใน SELECT clause
Scalar subquery คือ subquery ที่ถูกใช้ในตำแหน่งที่คาดหวังค่าเดียว (1 แถว 1 คอลัมน์)
-- แสดงทุกแผนกพร้อมจำนวนอาจารย์ในแผนกนั้น
SELECT dept_name,
(SELECT COUNT(*) FROM instructor
WHERE department.dept_name = instructor.dept_name) AS num_instructors
FROM department;
ถ้าใช้
SELECT dept_name, COUNT(*) FROM instructor GROUP BY dept_name แผนกที่ไม่มีอาจารย์เลยจะหายไปทั้งแถวถ้า scalar subquery คืนมากกว่า 1 แถว จะเกิด runtime error
8.6Database Modification
มี 3 อย่าง: ลบ tuple · เพิ่ม tuple ใหม่ · แก้ไขค่าใน tuple
DELETE
DELETE FROM instructor; -- ลบอาจารย์ทั้งหมด
DELETE FROM instructor WHERE dept_name = 'Finance';
-- ลบอาจารย์ที่สังกัดแผนกซึ่งตั้งอยู่ในตึก Watson
DELETE FROM instructor
WHERE dept_name IN (SELECT dept_name FROM department
WHERE building = 'Watson');
INSERT
INSERT INTO course VALUES ('CS-437','Database Systems','Comp. Sci.', 4);
-- เขียนระบุชื่อคอลัมน์ ได้ผลเหมือนกันแต่ปลอดภัยกว่า
INSERT INTO course (course_id, title, dept_name, credits)
VALUES ('CS-437','Database Systems','Comp. Sci.', 4);
-- ใส่ค่า null ให้ tot_creds
INSERT INTO student VALUES ('3003','Green','Finance', NULL);
-- INSERT … SELECT: เพิ่มอาจารย์ทุกคนเข้าตาราง student โดยให้ tot_creds = 0
INSERT INTO student
SELECT ID, name, dept_name, 0 FROM instructor;
ถ้าไม่ทำแบบนี้ query อย่าง
INSERT INTO table1 SELECT * FROM table1 จะกลายเป็นลูปไม่รู้จบ
(insert แถว → มีแถวใหม่ให้ select → insert อีก …)
UPDATE และ CASE
โจทย์: ขึ้นเงินเดือนอาจารย์ที่ได้เกิน $100,000 อีก 3% และคนอื่นอีก 5%
-- แบบที่มีปัญหา: เขียนสองคำสั่งแยกกัน — ลำดับสำคัญมาก!
UPDATE instructor SET salary = salary * 1.03 WHERE salary > 100000;
UPDATE instructor SET salary = salary * 1.05 WHERE salary <= 100000;
-- แบบที่ดีกว่า: คำสั่งเดียว ไม่มีปัญหาเรื่องลำดับ
UPDATE instructor
SET salary = CASE
WHEN salary > 100000 THEN salary * 1.03
ELSE salary * 1.05
END;
-- โจทย์ B7: จัดกลุ่มระดับเงินเดือนด้วย CASE ใน SELECT
SELECT first_name, last_name, salary,
CASE
WHEN salary >= 78000 THEN 'high'
WHEN salary >= 68000 THEN 'medium'
ELSE 'low'
END AS salary_band
FROM employees;
CASE ประเมินเงื่อนไขจากบนลงล่าง แล้วหยุดที่เงื่อนไขแรกที่เป็นจริง จึงต้องเรียงจากเงื่อนไขที่เจาะจงที่สุดไปหากว้างที่สุด
COALESCE
COALESCE คืนค่าแรกที่ไม่เป็น NULL จากรายการนิพจน์ที่ให้มา เป็นวิธีจัดการ NULL อย่างสง่างาม
COALESCE(expression1, expression2, expression3, …)
-- ประเมินจากซ้ายไปขวา คืนตัวแรกที่ไม่เป็น NULL
-- ถ้าเป็น NULL ทั้งหมด → คืน NULL
-- ตาราง contacts สมมติ (ไม่ใช่ employees ในบทนี้)
SELECT contact_name,
COALESCE(phone_number, 'No phone provided') AS contact_phone
FROM contacts;
-- ใช้กับ employees จริงในบทนี้: manager_id ของ John เป็น NULL
SELECT first_name, COALESCE(manager_id, 0) AS mgr_code FROM employees;
SELECT COALESCE(preferred_name, first_name, 'Unknown') AS display_name
FROM users;
SELECT product_name, price * COALESCE(discount_rate, 0) AS discount_amount
FROM products;
ตัวอย่างสุดท้ายสำคัญ — ถ้าไม่มี COALESCE สินค้าที่ discount_rate เป็น NULL จะได้ discount_amount = NULL
แทนที่จะเป็น 0 เพราะเลขคณิตกับ NULL ให้ผลเป็น NULL เสมอ
8.7ตะลุยโจทย์ Section B และ C
B1–B2 · Subquery ด้วย IN และ NOT IN
-- B1. พนักงานในแผนกที่ตั้งอยู่ New York หรือ Chicago
SELECT * FROM employees
WHERE department IN (SELECT department_name FROM departments
WHERE location IN ('New York','Chicago'));
-- B2. พนักงานที่ไม่ได้ถูกมอบหมายโปรเจกต์ใดเลย
SELECT * FROM employees
WHERE employee_id NOT IN (SELECT employee_id FROM employee_projects);
โจทย์ข้อนี้ตั้งใจให้ใช้ชุดที่เพิ่ม Nina (id 9) ซึ่งไม่มีแถวใน
employee_projects เลย → คำตอบคือ Ninaemployee_projects.employee_id มีค่า NULL อยู่แม้แถวเดียว
query นี้จะไม่คืนอะไรเลย · เขียนแบบปลอดภัย:
SELECT * FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employee_projects ep
WHERE ep.employee_id = e.employee_id);B4–B6 · FROM subquery, WITH, scalar subquery
-- B4. แผนกที่เงินเดือนเฉลี่ยเกิน 68,000
SELECT dept, avg_salary FROM
(SELECT department AS dept, AVG(salary) AS avg_salary
FROM employees GROUP BY department) AS d
WHERE avg_salary > 68000;
-- B5. แผนกที่ค่าใช้จ่ายเงินเดือนรวมเกิน 150,000
WITH dept_total(dept_name, total_salary) AS
(SELECT department, SUM(salary) FROM employees GROUP BY department)
SELECT * FROM dept_total WHERE total_salary > 150000;
-- B6. แต่ละแผนกพร้อมจำนวนพนักงาน (scalar subquery)
SELECT d.department_name,
(SELECT COUNT(*) FROM employees e
WHERE e.department = d.department_name) AS employee_count
FROM departments d;
B6 ใช้ scalar subquery ดีกว่า GROUP BY ตรงไหน — แผนก Legal (แถวที่เพิ่มในแบบฝึกหัด Part 2 & 3)
ไม่มีพนักงานเลย แต่ยังแสดงออกมาพร้อมค่า 0
ถ้าใช้ SELECT department, COUNT(*) FROM employees GROUP BY department แผนก Legal จะหายไปทั้งแถว
เพราะไม่มีแถวไหนใน employees ที่เป็นของ Legal ให้จัดกลุ่มตั้งแต่แรก
C1–C3 · คำสั่งที่เปลี่ยนข้อมูล (รันเป็นลำดับสุดท้าย)
-- C1. ลบโปรเจกต์ของแผนกที่อยู่ที่ Boston
DELETE FROM projects
WHERE department_id IN (SELECT department_id FROM departments
WHERE location = 'Boston');
-- C2. สร้างและเติมตาราง high_earners จากเงินเดือนปัจจุบัน
CREATE TABLE high_earners (
employee_id INT PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
salary DECIMAL(10,2)
);
INSERT INTO high_earners (employee_id, first_name, last_name, salary)
SELECT employee_id, first_name, last_name, salary
FROM employees WHERE salary >= 70000;
-- C3. ขึ้นเงินเดือน: ต่ำกว่า 65,000 ขึ้น 10% ที่เหลือขึ้น 5% ในคำสั่งเดียว
UPDATE employees
SET salary = CASE WHEN salary < 65000 THEN salary * 1.10
ELSE salary * 1.05 END;
ทำไมโจทย์ให้รัน C1 → C2 → C3 ตามลำดับ
เพราะ C2 คัดลอกเงินเดือนก่อนขึ้น ไปเก็บใน high_earners แล้ว C3 ค่อยไปแก้ตาราง employees
จึงเกิดคำถามชวนคิดว่า “ทำไม high_earners ถึงไม่เปลี่ยนตาม?” — คำตอบ: เพราะมันคือตารางจริงที่คัดลอกข้อมูล ไม่ใช่ view
(ถ้าเป็น view จะเปลี่ยนตาม ดูบทที่ 9)
8.8สรุปบทนี้
- Nested subquery ใช้ได้ใน WHERE, FROM และ SELECT — เปิดทางให้เขียน query ซับซ้อนและเปรียบเทียบข้อมูลได้
- ประเภท: set membership (IN) · set comparison (SOME, ALL) · existence (EXISTS)
- WITH clause นิยาม relation ชั่วคราว · Scalar subquery คืนค่าเดียว
- Database modification: DELETE ลบแถว · INSERT เพิ่มแถว · UPDATE แก้ข้อมูลเดิม
- CASE statement ทำให้ update แบบมีเงื่อนไขเป็นคำสั่งเดียว · COALESCE จัดการ NULL
- ต้องคำนึงถึงผลด้านประสิทธิภาพเสมอเมื่อ query ซับซ้อนขึ้น — โดยเฉพาะ correlated subquery ที่รันซ้ำทุกแถว
WHERE salary > ALL (SELECT salary FROM employees WHERE department='HR') เทียบเท่ากับข้อใดUPDATE instructor SET salary = salary*1.05 WHERE salary <= 100000; แล้วตามด้วย UPDATE instructor SET salary = salary*1.03 WHERE salary > 100000; มีปัญหาอะไร