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

SQL Part 2 — Predicates & JOIN

เงื่อนไขใน WHERE ทั้งหมด · INNER / LEFT / RIGHT / FULL OUTER JOIN · self join

SQL 60%ออกสอบแน่
หัวข้อในบทนี้

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

Introduction to SQL (Part 2) — ตอนที่ 1

6.1เงื่อนไขทั้งหมดใน WHERE clause

บทนี้รวบรวม predicate ทุกแบบที่ใช้ได้ใน WHERE — จำให้ครบเพราะข้อสอบเติมโค้ดชอบเว้นตรงนี้

ก่อนอ่านตาราง — “subquery” คืออะไร สาม predicate ท้ายตาราง (EXISTS, ANY/SOME, ALL) พูดถึง subquery ซึ่งยังไม่เคยเจอมาก่อน จึงต้องรู้ก่อนคร่าว ๆ:

subquery = คำสั่ง SELECT อีกอันที่เขียนไว้ในวงเล็บ ซ้อนอยู่ข้างใน query หลัก
ฐานข้อมูลจะรัน subquery ให้เสร็จก่อน ได้ออกมาเป็นชุดของค่า แล้ว query ข้างนอกค่อยเอาชุดนั้นไปเปรียบเทียบ
SELECT * FROM employees
WHERE  department IN ( SELECT department_name FROM departments
                       WHERE location = 'New York' );
-- ขั้นที่ 1: subquery รันก่อน  ได้ {'IT'}   (ดูตาราง departments ใน 6.2 — IT อยู่ New York)
-- ขั้นที่ 2: กลายเป็น  WHERE department IN ('IT')
ตาราง departments ที่อ้างถึงอยู่ที่ หัวข้อ 6.2 — มี 4 แผนก: IT (New York) · HR (Chicago) · Marketing (Los Angeles) · Finance (Boston)
แค่นี้พอสำหรับบทนี้ · รายละเอียดเต็ม ๆ อยู่บทที่ 8
Predicateความหมายตัวอย่าง
1. Comparison=   <> หรือ !=   <   >   <=   >=salary >= 70000
2. BETWEENเลือกค่าในช่วงที่กำหนด รวมปลายทั้งสองข้างhire_date BETWEEN '2019-01-01' AND '2020-12-31'
3. INระบุหลายค่าใน WHERE clausecountry IN ('USA','UK','Canada')
4. LIKEจับคู่ pattern · % = 0/1/หลายตัว · _ = 1 ตัวname LIKE 'John%'
5. IS NULL / IS NOT NULLตรวจค่าว่าง — ห้ามใช้ = NULLshipping_date IS NULL
6. AND OR NOTตัวดำเนินการตรรกะรวมหลายเงื่อนไขcategory='Electronics' AND (price<500 OR stock>100)
7. EXISTSตรวจว่ามีแถวที่ตรงเงื่อนไขของ subquery หรือไม่EXISTS (SELECT 1 FROM employees e
   WHERE e.department = d.department_name)
8. ANY / SOMEจริงถ้าค่าใดค่าหนึ่งใน subquery ทำให้เงื่อนไขเป็นจริงprice > ANY (SELECT price FROM products WHERE category='Books')
9. ALLจริงถ้าทุกค่าใน subquery ทำให้เงื่อนไขเป็นจริงsalary > ALL (SELECT salary FROM employees WHERE department = 'HR')
10. REGEXP / SIMILAR TOจับคู่ด้วย regular expression — ขึ้นกับระบบฐานข้อมูล
อ่าน pattern: ^ = ต้องอยู่ต้นสตริง · (a|b) = a หรือ b · ข้อสอบเน้น LIKE ตัวนี้แค่รู้จักไว้เทียบ
name REGEXP '^Apple (iPhone|iPad)'
LIKE vs REGEXP vs SIMILAR TO LIKE เป็น standard SQL ใช้ wildcard ง่าย ๆ (% และ _) — ใช้ได้ทุกระบบ
REGEXP ไม่ใช่ standard SQL แต่หลายระบบรองรับ (MySQL, PostgreSQL) ใช้ไวยากรณ์ regular expression เต็มรูปแบบ
SIMILAR TO รองรับน้อยที่สุด (มีใน PostgreSQL)

โจทย์ฝึกเขียน predicate จากสไลด์

ข้อฝึกชุดนี้ใช้ตารางสมมติของตัวเอง ไม่ใช่ฐานข้อมูล HR ในหัวข้อ 6.2 — เพื่อไม่ให้สับสน จะเรียกว่า:
emp_demo(id, name, department, salary, hire_date) · prod_demo(id, name, category, price, stock) · order_demo(id, customer_id, order_date, total_amount)
สังเกตว่าที่นี่มีคอลัมน์ name ช่องเดียว แต่ตาราง employees จริงในหัวข้อ 6.2 ใช้ first_name / last_name แยกกัน — เวลาสอบให้ดูชื่อคอลัมน์จากโจทย์เสมอ

ฟังก์ชันวันที่ที่ใช้ในเฉลยข้างล่าง
CURDATE()วันที่วันนี้
DATE_SUB(d, INTERVAL 5 YEAR)เอาวันที่ d ลบไป 5 ปี · เปลี่ยน YEAR เป็น DAY, MONTH ได้
DATEDIFF(a, b)ผลต่างระหว่างสองวันที่ เป็นจำนวนวัน
DAYOFWEEK(d)MySQL: 1 = อาทิตย์ … 7 = เสาร์
ฟังก์ชันพวกนี้เป็นของ MySQL — ข้อสอบเน้นตรรกะของ predicate มากกว่าชื่อฟังก์ชัน แต่รู้ไว้จะเขียนได้เร็วขึ้น
1. พนักงานที่ชื่อขึ้นต้นด้วย 'J' และอยู่กับบริษัทมาเกิน 5 ปี
SELECT * FROM emp_demo
WHERE  name LIKE 'J%'
  AND  hire_date < DATE_SUB(CURDATE(), INTERVAL 5 YEAR);

หรือใช้ DATEDIFF(CURDATE(), hire_date) > 365*5 ก็ได้ แต่แบบ INTERVAL อ่านง่ายและถูกต้องเรื่องปีอธิกสุรทินมากกว่า

2. สินค้าที่ชื่อมีคำว่า 'phone' (ไม่สนตัวพิมพ์) และของหมดสต๊อก
SELECT * FROM prod_demo
WHERE  LOWER(name) LIKE '%phone%'
  AND  stock = 0;

ทำไมต้อง LOWER() — pattern ของ LIKE เป็น case-sensitive จึงต้องแปลงให้เป็นตัวพิมพ์เล็กทั้งคู่ก่อนเทียบ (บาง collation ของ MySQL ไม่สนตัวพิมพ์อยู่แล้ว แต่เขียนแบบนี้ปลอดภัยกว่า)

3. ออเดอร์ใน 400 วันล่าสุดที่ยอดเกิน 1000
SELECT * FROM order_demo
WHERE  order_date >= DATE_SUB(CURDATE(), INTERVAL 400 DAY)
  AND  total_amount > 1000;
4. ออเดอร์ที่สั่งในวันเสาร์อาทิตย์
SELECT * FROM order_demo
WHERE  DAYOFWEEK(order_date) IN (1, 7);   -- MySQL: 1 = อาทิตย์, 7 = เสาร์

หรือ WHERE WEEKDAY(order_date) IN (5, 6) (MySQL: 0 = จันทร์ … 6 = อาทิตย์)

6.2JOIN — 4 ชนิดที่ต้องแยกออก

ชนิดคืนอะไร
INNER JOINคืนเฉพาะแถวที่จับคู่ได้จากทั้งสองตาราง
LEFT (OUTER) JOINคืนทุกแถวจากตารางซ้าย พร้อมแถวที่ตรงกันจากตารางขวา (ถ้าไม่มีคู่ → NULL)
RIGHT (OUTER) JOINคืนทุกแถวจากตารางขวา พร้อมแถวที่ตรงกันจากตารางซ้าย
FULL (OUTER) JOINคืนทุกแถวจากทั้งสองตาราง — แถวที่ไม่มีคู่จะได้ NULL ในฝั่งที่ขาด
MySQL ไม่มี FULL OUTER JOIN ฐานข้อมูลบางตัวรวมถึง MySQL ไม่รองรับ FULL JOIN โดยตรง ต้องจำลองด้วยการ UNION ของ LEFT JOIN กับ RIGHT JOIN
SELECT * FROM a LEFT  JOIN b ON a.id = b.a_id
UNION
SELECT * FROM a RIGHT JOIN b ON a.id = b.a_id;
ทำไมต้อง UNION ไม่ใช่ UNION ALL — เพราะแถวที่จับคู่ได้จะปรากฏในทั้งสอง query · UNION ลบแถวซ้ำให้ ส่วน UNION ALL จะได้แถวซ้ำ

4 ประเด็นสำคัญ (Key points จากสไลด์)

ตัวอย่างระบบห้องสมุด

ใช้ข้อมูลชุดนี้อ่านทั้งสี่การ์ดด้านล่าง — สังเกตว่ามีหนังสือที่ไม่เคยถูกยืม (B3) และผู้ยืมที่ไม่เคยยืม (Charlie) เตรียมไว้ให้เห็นผลของ outer join

books
book_idtitle
B1Database Systems
B2Operating Systems
B3Compilers

B3 ไม่เคยถูกยืม

borrowers
borrower_idname
1Alice Smith
2Bob Jones
3Charlie Brown

Charlie ไม่เคยยืมอะไร

loans
loan_idbook_idborrower_idloan_date
L1B112026-07-01
L2B222026-07-05
L3B122026-08-02
แสดงหนังสือที่ถูกยืม พร้อมชื่อผู้ยืมและวันที่ยืม
SELECT b.title, br.name, l.loan_date
FROM   loans l
JOIN   books     b  ON l.book_id     = b.book_id
JOIN   borrowers br ON l.borrower_id = br.borrower_id;

INNER JOIN — แสดงเฉพาะหนังสือที่ถูกยืมแล้ว · ผู้ยืมที่ยังไม่เคยยืม และหนังสือที่ยังไม่เคยถูกยืม จะไม่ปรากฏ

แสดงหนังสือทั้งหมดพร้อมสถานะการยืม รวมเล่มที่ไม่เคยถูกยืม
SELECT b.title, l.loan_date, br.name
FROM   books b
LEFT JOIN loans     l  ON b.book_id     = l.book_id
LEFT JOIN borrowers br ON l.borrower_id = br.borrower_id;

LEFT JOIN — รวมหนังสือทุกเล่ม เล่มที่ไม่เคยถูกยืมจะแสดง NULL ในคอลัมน์ loan_date และ name (ชื่อผู้ยืม) · ใช้ดูว่าเล่มไหนว่างหรือไม่เคยมีคนยืม

แสดงผู้ยืมทั้งหมด รวมคนที่ยังไม่เคยยืมอะไรเลย
SELECT br.name, b.title, l.loan_date
FROM   borrowers br
LEFT JOIN loans l ON br.borrower_id = l.borrower_id
LEFT JOIN books b ON l.book_id      = b.book_id;

รวมผู้ยืมทุกคนแม้ยังไม่เคยยืม (เช่น Charlie Brown) โดยแสดง NULL ในคอลัมน์ title และ loan_date

แสดงทุกหนังสือและทุกผู้ยืม — ทุกคู่ที่เป็นไปได้
SELECT b.title, br.name
FROM   books b
CROSS JOIN borrowers br;

CROSS JOIN = Cartesian product · ได้ (จำนวนหนังสือ × จำนวนผู้ยืม) แถว

ฐานข้อมูล HR ที่ใช้ตลอดบทนี้และบทที่ 7–8

เฉลยทุกข้อตั้งแต่หัวข้อ 6.3 เป็นต้นไป และบทที่ 7–8 อ้างข้อมูลชุดนี้ · ชื่อคอลัมน์ในตารางนี้คือชื่อจริงที่ใช้ใน query

อ่านก่อน: ชุดข้อมูลฐานคือ พนักงาน 8 คน (id 1–8) และ 4 แผนก — ตัวเลขในเฉลยส่วนใหญ่ (เช่น COUNT(*) = 8, COUNT(DISTINCT department) = 4) มาจากชุดนี้
แถวสีเหลือง 2 แถว (Nina id 9 และแผนก Legal id 5) เพิ่มเข้ามาเฉพาะในแบบฝึกหัด Part 2 & 3 เพื่อทดสอบเรื่อง outer join กับ NULL — ใช้เมื่อโจทย์พูดถึงมันเท่านั้น

employees
employee_idfirst_namelast_namedepartmentsalaryhire_datemanager_id
1JohnDoeIT750002020-01-15NULL
2JaneSmithHR650002019-05-111
3MikeJohnsonMarketing700002021-03-201
4EmilyBrownIT780002018-11-071
5DavidLeeFinance800002017-08-302
6SarahWilsonHR620002022-02-142
7ChrisAndersonMarketing690002020-07-223
8LisaTaylorIT720002019-09-014
9NinaPatelResearch600002023-06-01NULL

John เป็นคนเดียวที่ manager_id เป็น NULL — เขาไม่มีหัวหน้า
แถวสีเหลือง (Nina) เพิ่มเข้ามาในแบบฝึกหัด Part 2 & 3 — แผนก Research ของเธอไม่มีในตาราง departments

departments
department_iddepartment_namelocation
1ITNew York
2HRChicago
3MarketingLos Angeles
4FinanceBoston
5LegalSeattle

แถวสีเหลือง (Legal) เพิ่มในแบบฝึกหัด Part 2 & 3 — เป็นแผนกที่ไม่มีพนักงานสักคน

projects
project_idproject_namestart_datedepartment_id
1Website Redesign2023-01-011
2Employee Training2023-03-152
3Product Launch2023-05-013
4Financial Upgrade2023-02-014
5Mobile App2023-04-011
employee_projects — junction table แบบ M:N
employee_idproject_idrole
11Project Manager
41Developer
81Designer
22Trainer
62Coordinator
33Campaign Manager
73Content Creator
54Financial Analyst
15Technical Lead
45Developer
จุดที่ต้องสังเกตในข้อมูลชุดนี้ 1. employees.department เก็บเป็นชื่อแผนก (ข้อความ) ไม่ใช่ id — เวลา join กับ departments จึงต้องเชื่อม e.department = d.department_name ไม่ใช่เชื่อมด้วย id
2. แต่ projects.department_id เก็บเป็นตัวเลข — เชื่อมกับ departments.department_id
3. สองแถวสีเหลือง (Legal และ Nina) มีเฉพาะในแบบฝึกหัด Part 2 & 3 เพื่อเปิดเผยด้าน NULL ของ outer join:
• Legal = แผนกที่ไม่มีพนักงาน → โผล่เฉพาะเมื่อใช้ RIGHT JOIN จาก employees (หรือ LEFT JOIN จาก departments)
• Nina = พนักงานที่แผนกไม่มีในตาราง departments → หายไปทันทีถ้าใช้ INNER JOIN
ถ้าโจทย์ไม่ได้พูดถึงสองแถวนี้ ให้ใช้ข้อมูล 8 คน / 4 แผนกเดิม

6.3Self Join — join ตารางกับตัวเอง

ใช้เมื่อความสัมพันธ์อยู่ภายในตารางเดียวกัน เช่น พนักงานมีหัวหน้าซึ่งก็เป็นพนักงานเหมือนกัน

โจทย์ข้อ 9 — พนักงานที่เงินเดือนสูงกว่าหัวหน้าตัวเอง
SELECT e1.first_name, e1.last_name, e1.salary AS employee_salary,
       e2.first_name AS manager_first_name,
       e2.last_name  AS manager_last_name,
       e2.salary     AS manager_salary
FROM   employees e1
JOIN   employees e2 ON e1.manager_id = e2.employee_id
WHERE  e1.salary > e2.salary;

เคล็ดลับ: ตั้ง alias สองชื่อให้ตารางเดียวกัน (e1 = ลูกน้อง, e2 = หัวหน้า) แล้วเชื่อมด้วย e1.manager_id = e2.employee_id · ถ้าใช้ INNER JOIN คนที่ไม่มีหัวหน้า (manager_id = NULL) จะหายไป ซึ่งถูกต้องแล้วในโจทย์นี้

6.4ตะลุยโจทย์ Part 2 — พร้อมเฉลย

ใช้ข้อมูลชุดเดียวกับหัวข้อ 6.2 ด้านบน — เลื่อนกลับขึ้นไปดูตารางได้ตลอด

1–5 · WHERE predicates
-- 1. เงินเดือนเกิน 70,000
SELECT * FROM employees WHERE salary > 70000;

-- 2. เข้าทำงานระหว่าง 1 ม.ค. 2019 ถึง 31 ธ.ค. 2020
SELECT * FROM employees
WHERE hire_date BETWEEN '2019-01-01' AND '2020-12-31';

-- 3. อยู่แผนก IT หรือ Finance
SELECT * FROM employees WHERE department IN ('IT','Finance');

-- 4. นามสกุลลงท้ายด้วย 'son'
SELECT * FROM employees WHERE last_name LIKE '%son';

-- 5. IT ที่เงินเดือน > 70000 หรือ HR ที่เงินเดือน < 65000
SELECT * FROM employees
WHERE (department = 'IT' AND salary > 70000)
   OR (department = 'HR' AND salary < 65000);

ข้อ 5 ต้องมีวงเล็บ — ถ้าเขียน department='IT' AND salary>70000 OR department='HR' AND salary<65000 โดยไม่ใส่วงเล็บ ก็ยังได้ผลถูกเพราะ AND มี precedence สูงกว่า OR แต่ใส่วงเล็บเสมอเพื่อความชัดเจนและกันพลาด

6–8 · JOIN
-- 6. พนักงานพร้อมสถานที่ตั้งของแผนก
SELECT e.employee_id, e.first_name, e.last_name,
       d.department_name, d.location
FROM   employees e
INNER JOIN departments d ON e.department = d.department_name;

-- 7. ทุกแผนกพร้อมพนักงานในแผนก (ถ้ามี)
SELECT d.department_name, e.first_name, e.last_name
FROM   departments d
LEFT JOIN employees e ON d.department_name = e.department;

-- 8. พนักงานทุกคน + สถานที่แผนก + โปรเจกต์ที่ทำ (ถ้ามี)
SELECT e.first_name, e.last_name, d.location, p.project_name
FROM   employees e
LEFT JOIN departments d        ON e.department    = d.department_name
LEFT JOIN employee_projects ep ON e.employee_id   = ep.employee_id
LEFT JOIN projects p           ON ep.project_id   = p.project_id;

ข้อ 7 ทำไมต้องเป็น LEFT JOIN จาก departments — โจทย์บอกว่า “ทุกแผนก … (ถ้ามี)” แปลว่าแผนกที่ไม่มีพนักงานก็ต้องแสดง ถ้าใช้ INNER JOIN แผนก Legal จะหายไป

ข้อ 8 ต้องเป็น LEFT JOIN ทั้งสามที่ — โจทย์บอก “พนักงานทุกคน” จึงห้ามมีใครหายไปเลย
• LEFT JOIN departments — เพราะ Nina อยู่แผนก Research ซึ่งไม่มีในตาราง departments ถ้าใช้ INNER JOIN เธอจะหายไปทันที ทั้งที่โจทย์ขอ “ทุกคน”
• LEFT JOIN employee_projects / projects — เพราะโจทย์บอก “ถ้ามี” คนที่ไม่มีโปรเจกต์ก็ต้องแสดง
ลำดับสำคัญ: เมื่อ LEFT JOIN ตารางแรกแล้ว ต้อง LEFT JOIN ตารางถัดไปด้วย ไม่งั้น INNER JOIN ตัวหลังจะไปตัดแถว NULL ทิ้งจนความพยายามข้างหน้าสูญเปล่า

9–13 · Self join, EXISTS, ANY, ALL
-- 9. พนักงานที่เงินเดือนสูงกว่าหัวหน้าตัวเอง
SELECT e1.first_name, e1.last_name, e1.salary AS employee_salary,
       e2.first_name AS manager_first_name, e2.salary AS manager_salary
FROM   employees e1
JOIN   employees e2 ON e1.manager_id = e2.employee_id
WHERE  e1.salary > e2.salary;

-- 10. พนักงานที่ไม่อยู่ IT และไม่ได้เข้าทำงานก่อนปี 2019
SELECT * FROM employees
WHERE NOT (department = 'IT' OR hire_date < '2019-01-01');

-- 11. พนักงานที่เป็นหัวหน้า (มีอย่างน้อย 1 คนรายงานตรง)
SELECT * FROM employees e
WHERE EXISTS (SELECT 1 FROM employees r
              WHERE r.manager_id = e.employee_id);

-- 12. เงินเดือนสูงกว่าอย่างน้อยหนึ่งคนใน HR
SELECT * FROM employees
WHERE salary > ANY (SELECT salary FROM employees WHERE department = 'HR');

-- 13. เงินเดือนสูงกว่าทุกคนใน HR
SELECT * FROM employees
WHERE salary > ALL (SELECT salary FROM employees WHERE department = 'HR');

ข้อ 11 อ่านยังไง — EXISTS ทำงานทีละแถว
e คือชื่อเรียกแถวที่ query ข้างนอกกำลังพิจารณาอยู่ · สำหรับแต่ละแถว e ฐานข้อมูลจะรัน query ข้างในใหม่หนึ่งรอบ เพื่อถามว่า “มีพนักงานคนไหนที่ manager_id เท่ากับ e.employee_id ไหม”
ถ้าเจออย่างน้อยหนึ่งแถว EXISTS เป็นจริง → แถว e ถูกเก็บไว้ · ที่เขียน SELECT 1 เพราะสนแค่ว่า “มีไหม” ไม่สนว่าค่าคืออะไร
แบบนี้เรียกว่า correlated subquery (query ข้างในอ้างถึงตัวแปรจากข้างนอก) — เรียนเต็ม ๆ บทที่ 8

ข้อ 12 กับ 13 คือคู่ที่ออกสอบบ่อยที่สุด
HR มีเงินเดือน 65,000 (Jane) และ 62,000 (Sarah)
> ANY = มากกว่าค่าน้อยสุด = > 62,000 → ได้ทุกคนยกเว้น Sarah
> ALL = มากกว่าค่ามากสุด = > 65,000 → ได้ John, Mike, Emily, David, Chris, Lisa
จำง่าย ๆ: ANY → MIN · ALL → MAX

ข้อ 10 กระจาย NOT ด้วยกฎเดอมอร์แกน: NOT (A OR B) ≡ NOT A AND NOT B = department <> 'IT' AND hire_date >= '2019-01-01'
แต่ระวัง! ถ้ามีแถวที่ department เป็น NULL ทั้งสองรูปแบบจะตัดแถวนั้นทิ้ง เพราะได้ผลเป็น UNKNOWN (ดูบทที่ 7)


ตาราง A มี 3 แถว B มี 3 แถว จับคู่กันได้ 2 คู่ · A LEFT JOIN B จะได้กี่แถว (สมมติจับคู่แบบ 1 ต่อ 1)
LEFT JOIN คืนทุกแถวของตารางซ้ายเสมอ = 3 แถว · 2 แถวมีข้อมูลจาก B ครบ ส่วนอีก 1 แถวคอลัมน์ฝั่ง B เป็น NULL
ถ้าเป็น INNER JOIN จะได้ 2 แถว · ถ้าเป็น CROSS JOIN จะได้ 9 แถว
ต้องการ “แสดงทุกแผนก รวมแผนกที่ยังไม่มีพนักงาน” ควรเขียนอย่างไร
ตารางที่ต้อง “แสดงให้ครบ” ต้องอยู่ฝั่งซ้ายของ LEFT JOIN · เขียน FROM employees e RIGHT JOIN departments d ก็ได้ผลเหมือนกัน แต่แบบ LEFT อ่านง่ายกว่า
ระบบจำไว้ในเบราว์เซอร์นี้ จะได้รู้ว่าอ่านถึงไหนแล้ว