EN813304 · Midterm
บทที่ 8 / 12 · sql-part3.pptx · สัปดาห์ที่ 7

SQL Part 3 — Subqueries & Database Modification

subquery ใน WHERE / FROM / SELECT · IN · SOME · ALL · EXISTS · WITH · scalar subquery · INSERT UPDATE DELETE · CASE · COALESCE

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

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

Introduction to SQL (Part 3)
ข้อมูลที่ใช้ในบทนี้ — มีสองชุด อย่าสับสน 1. ชุดมหาวิทยาลัย (section, teaches, instructor, department, student, takes) ใช้กับตัวอย่างที่ยกมาจากสไลด์/ตำรา — โครงสร้างเต็มอยู่ที่ บทที่ 5 หัวข้อ 5.3
2. ชุด 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 ที่:

WHERE clause

ทดสอบเงื่อนไข — set membership (IN) · set comparison (SOME, ALL) · existence (EXISTS)

FROM clause

ใช้ผลลัพธ์ของ subquery เป็นตารางชั่วคราว (derived table) — ต้องตั้ง alias เสมอ

SELECT clause

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
กับดัก NOT IN กับ NULL — ต้องรู้ ถ้า subquery คืนค่า NULL มาด้วยแม้แถวเดียว x NOT IN (…, NULL) จะให้ผลเป็น UNKNOWN เสมอ → WHERE ไม่เก็บแถวไหนเลย → query คืนผลลัพธ์ว่าง ทั้งที่โค้ดดูถูกทุกอย่าง

วิธีแก้: เติม WHERE col IS NOT NULL ใน subquery หรือใช้ NOT EXISTS แทน

Set comparison — SOME / ANY และ ALL

> SOME (= > ANY)
SELECT first_name, salary FROM employees
WHERE salary > SOME (SELECT salary FROM employees
                    WHERE department = 'Marketing');

จริงถ้าเงินเดือนมากกว่าอย่างน้อยหนึ่งคนในแผนก Marketing
≡ > MIN(เซตนั้น)

> ALL
SELECT first_name, salary FROM employees
WHERE salary > ALL (SELECT salary FROM employees
                   WHERE department = 'Marketing');

จริงก็ต่อเมื่อเงินเดือนมากกว่าทุกคนในแผนก Marketing
≡ > MAX(เซตนั้น)

สูตรจำสั้น ๆ > ANY → > MIN · > ALL → > 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);
-- โจทย์ข้อ 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;
สองข้อที่พลาดบ่อย 1. ต้องตั้ง alias ให้ derived table เสมอ — MySQL จะฟ้อง Every derived table must have its own alias
2. ต้องตั้งชื่อ (alias) ให้คอลัมน์ที่เป็น aggregate ไม่งั้นอ้างถึงจากข้างนอกไม่ได้

วิธีนี้เป็นทางเลือกแทน HAVING — ทั้ง HAVING AVG(salary) > 70000 และการซ้อน subquery ใน FROM ให้ผลเหมือนกัน แต่แบบ FROM อ่านง่ายกว่าเมื่อเงื่อนไขซับซ้อนขึ้น

8.4WITH clause (Common Table Expression)

WITH clause ให้เรานิยาม relation ชั่วคราว ซึ่งใช้ได้เฉพาะภายใน query ที่ประกาศไว้เท่านั้น พอ query จบ ตารางชั่วคราวนั้นก็หายไป — ไม่ได้ถูกเก็บลงดิสก์เหมือน CREATE TABLE

อ่านโครงสร้างของ WITH ให้ออก
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;
ข้อดีเทียบกับ GROUP BY subquery แบบนี้แสดงทุกแผนกรวมแผนกที่มีอาจารย์ 0 คน (ได้ค่า 0)
ถ้าใช้ 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;
จุดสำคัญของ INSERT … SELECT คำสั่ง select-from-where จะถูกประเมินจนเสร็จสมบูรณ์ก่อน แล้วผลลัพธ์จึงถูกใส่เข้า relation
ถ้าไม่ทำแบบนี้ 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;
ทำไมลำดับถึงสำคัญ ถ้ารัน 5% ก่อน คนที่ได้ 99,000 จะกลายเป็น 103,950 แล้วเมื่อรันคำสั่ง 3% ต่อ เขาจะได้ขึ้นซ้ำอีกรอบ รวมเป็นราว 8% ซึ่งผิดเจตนาของโจทย์
-- แบบที่ดีกว่า: คำสั่งเดียว ไม่มีปัญหาเรื่องลำดับ
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);
B2 บนข้อมูลชุดฐาน (8 คน) ได้ผลลัพธ์ว่าง — เพราะพนักงาน id 1–8 มีโปรเจกต์กันครบทุกคน
โจทย์ข้อนี้ตั้งใจให้ใช้ชุดที่เพิ่ม Nina (id 9) ซึ่งไม่มีแถวใน employee_projects เลย → คำตอบคือ Nina
B2 คือกับดัก NOT IN + NULL ด้วย — ถ้า employee_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สรุปบทนี้

HR มีเงินเดือน 65,000 และ 62,000 · WHERE salary > ALL (SELECT salary FROM employees WHERE department='HR') เทียบเท่ากับข้อใด
> ALL = มากกว่าทุกค่า = มากกว่าค่าสูงสุด = > 65,000 · ส่วน > ANY/SOME = มากกว่าอย่างน้อยหนึ่งค่า = มากกว่าค่าต่ำสุด = > 62,000
UPDATE instructor SET salary = salary*1.05 WHERE salary <= 100000; แล้วตามด้วย UPDATE instructor SET salary = salary*1.03 WHERE salary > 100000; มีปัญหาอะไร
คนที่ได้ 99,000 → ขึ้น 5% เป็น 103,950 → ตอนนี้ > 100,000 แล้ว จึงโดนคำสั่งที่สองขึ้นอีก 3% รวมเป็น ~8% · แก้ด้วยการสลับลำดับ หรือดีที่สุดคือใช้ CASE ในคำสั่งเดียว
ระบบจำไว้ในเบราว์เซอร์นี้ จะได้รู้ว่าอ่านถึงไหนแล้ว