EN813304 · Midterm
แบบฝึกหัด

แบบฝึกหัดในคาบ + เฉลย

วิชานี้ไม่มีข้อสอบเก่า — ที่รวมไว้นี่คือแบบฝึกหัดและงานในคาบของเทอมนี้ทั้งหมดก่อนกลางภาค บวกโจทย์ที่แต่งเพิ่มตามหัวข้อที่อาจารย์ประกาศไว้ในสโคปสอบ กดที่หัวข้อเพื่อดูเฉลย — ลองทำเองก่อนเปิดดูเสมอ

Intro to DB 25% SQL 60% Design 15%
รูปแบบข้อสอบที่ประกาศไว้ 1. Short answer — ต้องอธิบายและวิเคราะห์แนวคิด
2. Fill-in-the-code — เติมหรือเขียนโค้ดให้สมบูรณ์
3. Problem-solving scenarios — โจทย์สถานการณ์ที่ต้องประยุกต์ใช้แนวคิดในวิชา

เขียนตัวใหญ่ ๆ ชัด ๆ ด้วยปากกาน้ำเงินหรือดำ · ตรวจก่อนส่งว่าอ่านออก

AIntroduction to Databases

A1. อธิบายข้อเสียของการใช้ file system เก็บข้อมูล มาอย่างน้อย 5 ข้อ พร้อมยกตัวอย่าง
  1. Data redundancy and inconsistency — ไฟล์หลายรูปแบบ ข้อมูลชุดเดียวถูกเก็บซ้ำในหลายไฟล์ · เช่น ที่อยู่ลูกค้าอยู่ทั้งในไฟล์การขายและไฟล์การจัดส่ง แก้ที่หนึ่งลืมอีกที่
  2. Difficulty in accessing data — ทุกคำถามใหม่ต้องเขียนโปรแกรมใหม่
  3. Data isolation — ข้อมูลกระจายอยู่หลายไฟล์หลายฟอร์แมต
  4. Integrity problems — กฎเช่น “ยอดคงเหลือ > 0” ถูกฝังในโค้ด ไม่ได้ประกาศชัดเจน แก้กฎยาก
  5. Atomicity of updates — ระบบล่มกลางคัน ทำให้ข้อมูลค้างครึ่ง ๆ กลาง ๆ · เช่น โอนเงินแล้วหักออกสำเร็จแต่ยังไม่ทันเพิ่มปลายทาง
  6. Concurrent access anomalies — สองคนอ่านยอด 100 พร้อมกัน ต่างคนต่างถอน 50 ควรเหลือ 0 แต่กลับเหลือ 50
  7. Security problems — ให้ผู้ใช้เห็นข้อมูลบางส่วนแต่ไม่ทั้งหมดได้ยาก · OS ให้แค่รหัสผ่านซึ่งยืดหยุ่นไม่พอ
A2. อธิบาย three levels of abstraction และ physical data independence

Physical level — อธิบายว่า record ถูกเก็บอย่างไร (ไฟล์ บล็อก index บนดิสก์)
Logical level — อธิบายว่าเก็บข้อมูลอะไรและข้อมูลสัมพันธ์กันอย่างไร คือ schema
View level — ส่วนของฐานข้อมูลที่แอปหนึ่ง ๆ เห็น ซ่อนรายละเอียดชนิดข้อมูล และซ่อนข้อมูลเช่นเงินเดือนเพื่อความปลอดภัย

Physical data independence = ความสามารถในการแก้ physical schema โดยไม่ต้องแก้ logical schema
เพราะแอปพลิเคชันผูกกับ logical schema การเปลี่ยนวิธีจัดเก็บบนดิสก์จึงไม่กระทบโปรแกรม
โดยทั่วไปอินเทอร์เฟซระหว่างชั้นและระหว่างองค์ประกอบต่าง ๆ ต้องนิยามให้ชัด เพื่อให้การเปลี่ยนแปลงบางส่วนไม่กระทบส่วนอื่นอย่างรุนแรง

A3. schema กับ instance ต่างกันอย่างไร · ถ้าเพิ่มนักศึกษา 1 คน อะไรเปลี่ยน

Schema = โครงสร้างเชิงตรรกะโดยรวมของฐานข้อมูล เทียบได้กับ type ในภาษาโปรแกรม · เปลี่ยนไม่บ่อย
Instance = เนื้อหาจริงของฐานข้อมูล ณ จุดเวลาหนึ่ง เทียบได้กับ value ของตัวแปร · เปลี่ยนตลอดเวลา

เพิ่มนักศึกษา 1 คน → instance เปลี่ยน · schema ไม่เปลี่ยน เพราะโครงสร้างคอลัมน์ยังเหมือนเดิมทุกประการ

A4. องค์ประกอบของ database engine มีอะไรบ้าง แต่ละส่วนทำอะไร

1. Storage manager — โมดูลที่เป็นตัวกลางระหว่างข้อมูลระดับล่างในฐานข้อมูล กับโปรแกรมและ query ที่ส่งเข้ามา · รับผิดชอบการติดต่อกับ file manager ของ OS และการจัดเก็บ/ดึง/อัปเดตข้อมูลอย่างมีประสิทธิภาพ · ประเด็นที่เกี่ยวข้อง: storage access, file organization, indexing และ hashing

2. Query processor — ทำ 3 ขั้น: (1) parsing and translation (2) optimization (3) evaluation · query เดียวกันประเมินได้หลายวิธี (นิพจน์ที่สมมูลกัน และอัลกอริทึมที่ต่างกันสำหรับแต่ละ operation) ความต่างของต้นทุนระหว่างวิธีที่ดีกับแย่นั้นมหาศาล จึงต้องประมาณต้นทุนโดยอาศัยข้อมูลสถิติของ relation

3. Transaction manager — transaction คือกลุ่มของ operation ที่ทำหน้าที่เชิงตรรกะหนึ่งอย่าง · transaction-management component รักษาให้ฐานข้อมูลอยู่ในสถานะที่ถูกต้องแม้เกิด system failure หรือ transaction failure · concurrency-control manager ควบคุมการทำงานร่วมกันของ transaction ที่รันพร้อมกัน

A5. NoSQL กับ NewSQL ต่างกันอย่างไร ควรเลือกใช้ NoSQL เมื่อไหร่

NoSQL — key-value store และ document-oriented database ที่เร็ว · ไม่ต้องมี schema ตายตัว · เลี่ยง join ด้วยการเก็บข้อมูลแบบ denormalized · ออกแบบให้ scale แนวนอน · ตัวอย่าง MongoDB (document), Cassandra (column-family), Neo4j (graph), Redis (key-value)

เลือก NoSQL เมื่อ แอปต้องการ horizontal scalability · schema ยืดหยุ่นหรือเปลี่ยนไปเรื่อย ๆ · ต้องจัดการข้อมูลไร้โครงสร้างปริมาณมหาศาลที่ตาราง relational จำลองได้ไม่มีประสิทธิภาพ

NewSQL — RDBMS สมัยใหม่ที่รวมความสามารถในการขยายและความยืดหยุ่นของ NoSQL เข้ากับการรับประกัน ACID และความเข้ากันได้กับ SQL ของ RDBMS ดั้งเดิม · คุณสมบัติหลัก: horizontal scalability, strong consistency, SQL compatibility, high performance · ตัวอย่าง Google Spanner, CockroachDB

BRelational Model & Keys

B1. ให้ Student(student_id, national_id, email, full_name) โดยสามคอลัมน์แรกไม่ซ้ำ · ระบุ superkey ทั้งหมด candidate key และเลือก primary key พร้อมเหตุผล

Superkeys — เซตใดก็ตามที่มี candidate key อยู่ข้างใน:
{student_id}, {national_id}, {email}, {student_id, full_name}, {student_id, email}, {national_id, email}, {student_id, national_id, email, full_name} … รวมทุกเซตที่มี student_id หรือ national_id หรือ email อยู่

Candidate keys — superkey ที่ minimal: {student_id}, {national_id}, {email}

Primary key = student_id · เหตุผล: ระบบมหาวิทยาลัยออกให้และรับประกันว่าไม่ซ้ำ · email เปลี่ยนได้ จึงไม่เหมาะเป็น PK (PK ควรเป็นค่าที่ไม่เคยหรือแทบไม่เคยเปลี่ยน) · national_id มีประเด็นความเป็นส่วนตัว

{full_name} ไม่ใช่แม้แต่ superkey เพราะคนชื่อซ้ำกันได้

B2. {student_id, national_id} เป็น superkey หรือ candidate key · อธิบายความต่าง

เป็น superkey แต่ไม่ใช่ candidate key

เป็น superkey เพราะระบุ tuple ได้เฉพาะตัว (ถ้า K เป็น superkey แล้ว superset ของ K ก็เป็น superkey)

ไม่ใช่ candidate key เพราะไม่ minimal — ตัด national_id ทิ้งแล้ว {student_id} เพียงตัวเดียวก็ยังระบุ tuple ได้เฉพาะตัว

นิยาม candidate key = minimal superkey คือ superkey ที่ไม่มีสับเซตแท้ตัวใดเป็น superkey

B3. อธิบาย foreign key constraint · ทำไม INSERT INTO Enrollment VALUES ('9999999999','EN813304','1',2026,'A') ถึงล้มเหลว

Foreign key = attribute ใน relation r₁ ที่ค่าต้องปรากฏเป็นค่า primary key ของ tuple ใด tuple หนึ่งใน relation r₂ · r₁ = referencing relation · r₂ = referenced relation

FK constraint ทำสองอย่าง: (1) ป้องกันการกระทำที่จะทำลายลิงก์ระหว่างตาราง (2) ป้องกันการใส่ข้อมูลที่ไม่ถูกต้องลงคอลัมน์ FK

INSERT นี้ล้มเหลว เพราะ student_id = '9999999999' ไม่มีอยู่ในตาราง Student ซึ่งละเมิด foreign key constraint FOREIGN KEY (student_id) REFERENCES Student(student_id)
MySQL ตอบว่า: Cannot add or update a child row: a foreign key constraint fails
นี่คือการทำงานของ referential integrity

B4. ทำไม PK ของ Enrollment ต้องเป็น (student_id, course_id, semester, year) · student_id เดี่ยว ๆ ไม่ได้เหรอ

student_id เดี่ยว ๆ เป็น PK ไม่ได้ เพราะนักศึกษาหนึ่งคนลงทะเบียนได้หลายวิชา เช่นในข้อมูลมี ('6401001001','EN813304',…) และ ('6401001001','EN813201',…) → ค่า student_id ซ้ำกัน จึงระบุ tuple ไม่ได้เฉพาะตัว

ต้องมี semester และ year ด้วย เพราะนักศึกษาคนเดิมลงวิชาเดิมซ้ำได้ในภาคเรียนหรือปีต่างกัน (เช่น ตกแล้วลงใหม่)

คำถามต่อ: (student_id, course_id, semester, year, grade) เป็น superkey แต่ไม่ใช่ candidate key เพราะตัด grade ออกได้แล้วยังระบุได้เฉพาะตัว

CRelational Algebra

ใช้ schema: Student(student_id, name, major, gpa) · Course(course_id, title, credits, dept_name) · Enrollment(student_id, course_id, semester, year, grade)

ทบทวนสัญลักษณ์ก่อนทำ — σ (sigma) = select กรองแถว · Π (pi) = project เลือกคอลัมน์ แล้วตัดแถวซ้ำอัตโนมัติ · × = Cartesian product จับคู่ทุกแถว · ⋈ = natural join จับคู่เฉพาะที่ตรงกัน · ∪ ∩ − = union / intersect / difference · ρ (rho) = rename · ∧ ∨ ¬ = and / or / not
ทบทวนบทที่ 3 แบบเต็ม

C1. เขียนนิพจน์: หารายวิชาทั้งหมดของภาค CoE ที่มี 3 หน่วยกิต
σ dept_name='CoE' ∧ credits=3 (Course)

เขียนแบบซ้อนกันก็สมมูลกัน: σ dept_name='CoE' ( σ credits=3 (Course) )

C2. เขียนนิพจน์: หา course_id ที่มีการลงทะเบียนปี 2026 แต่ไม่มีปี 2025
Π course_id ( σ year=2026 (Enrollment) )
   −
Π course_id ( σ year=2025 (Enrollment) )

ต้อง Π ก่อน − เพราะ set difference ต้องการ arity เท่ากันและ domain เข้ากันได้

C3. เขียนนิพจน์: หาชื่อนักศึกษาที่ลงทะเบียนวิชา 'EN813304'
Π name ( Student ⋈ σ course_id='EN813304' (Enrollment) )

เขียนด้วย operator พื้นฐานล้วน:

Π name ( σ Student.student_id = Enrollment.student_id
       ( Student × σ course_id='EN813304' (Enrollment) ) )
C4. คู่นี้สมมูลกันหรือไม่ · Πname(σgpa>3.0(S)) vs σgpa>3.0(Πname(S))

ไม่สมมูลกัน · นิพจน์ที่สองทำ Πname ก่อน ซึ่งตัดคอลัมน์ gpa ทิ้งไปแล้ว จากนั้นจึงพยายามกรองด้วย gpa > 3.0 บน relation ที่ไม่มี attribute ชื่อ gpa เหลืออยู่ → นิพจน์นี้ไม่ถูกต้อง (invalid)

บทเรียน: จะ push σ ลงไปใต้ Π ได้ ก็ต่อเมื่อ attribute ที่ σ ใช้ยังคงอยู่หลังการ project

ต่างจาก σA∧B(r) ≡ σA(σB(r)) ซึ่งสมมูลกัน — ต่างกันแค่ประสิทธิภาพ

C5. Πcourse_id(σyear=2026(Enrollment)) ได้กี่แถว เมื่อปี 2026 มี EN813304 สามครั้งและ EN812201 หนึ่งครั้ง

2 แถว — {EN813304, EN812201}

เพราะ relation คือเซต → Π ลบแถวที่ซ้ำออกอัตโนมัติ

ถ้าเป็น SQL SELECT course_id FROM Enrollment WHERE year=2026 จะได้ 4 แถว ต้องเขียน SELECT DISTINCT ถึงจะเหมือน Π — ความต่างนี้ออกสอบบ่อย

DSQL — เขียน query

ใช้ฐานข้อมูล HR ชุดเดียวกับบทที่ 6 — เปิดตารางข้อมูลจริงได้ที่นี่
employees(employee_id, first_name, last_name, department, salary, hire_date, manager_id) · departments(department_id, department_name, location) · projects(project_id, project_name, start_date, department_id) · employee_projects(employee_id, project_id, role)

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

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

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

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

-- D5. IT ที่ > 70000 หรือ HR ที่ < 65000
SELECT * FROM employees
WHERE (department='IT' AND salary>70000) OR (department='HR' AND salary<65000);
D6. แสดงทุกแผนกพร้อมพนักงานในแผนก รวมแผนกที่ยังไม่มีพนักงาน
SELECT d.department_name, e.first_name, e.last_name
FROM   departments d
LEFT JOIN employees e ON d.department_name = e.department;

ต้องเป็น LEFT JOIN โดยให้ departments อยู่ซ้าย ถ้าใช้ INNER JOIN แผนกที่ไม่มีพนักงานจะหายไป

D7. หาพนักงานที่เงินเดือนสูงกว่าหัวหน้าตัวเอง
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;

Self join — ตั้ง alias สองชื่อให้ตารางเดียวกัน

D8. แสดง COUNT(*) เทียบกับ COUNT(manager_id) และอธิบายว่าทำไมต่างกัน
SELECT COUNT(*) AS all_employees, COUNT(manager_id) AS with_manager
FROM   employees;

จากข้อมูล employees 8 แถวในหัวข้อ D (ดูตารางเต็มในบทที่ 6) ซึ่ง John เป็นคนเดียวที่ manager_id เป็น NULL:

COUNT(*) นับทุกแถว = 8 · COUNT(manager_id) นับเฉพาะค่าที่ไม่เป็น NULL = 7

กฎ: aggregate ทุกตัวยกเว้น COUNT(*) จะเมิน NULL · ถ้าคอลัมน์มีแต่ NULL: COUNT → 0 · ตัวอื่น → NULL

D9. หาแผนกที่มีพนักงานมากกว่า 1 คน และแผนกที่เงินเดือนเฉลี่ยเกิน 70,000
SELECT department, COUNT(*) AS num_employees
FROM   employees GROUP BY department HAVING COUNT(*) > 1;

SELECT department, AVG(salary) AS avg_salary
FROM   employees GROUP BY department HAVING AVG(salary) > 70000;

ต้องใช้ HAVING ไม่ใช่ WHERE เพราะ WHERE ทำงานก่อน GROUP BY จึงยังไม่มีกลุ่มให้คำนวณ aggregate

D10. หาพนักงานที่เงินเดือนสูงกว่าค่าเฉลี่ยของแผนกตัวเอง
SELECT * FROM employees e
WHERE  salary > (SELECT AVG(salary) FROM employees
                 WHERE department = e.department);

Correlated subquery — inner query อ้างถึง e จาก outer query จึงถูกรันใหม่ทุกแถว

D11. หาแผนกที่ค่าใช้จ่ายเงินเดือนรวมเกิน 150,000 โดยใช้ WITH clause
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;
D12. แสดงทุกแผนกพร้อมจำนวนพนักงาน โดยแผนกที่ไม่มีพนักงานต้องแสดงเป็น 0
SELECT d.department_name,
       (SELECT COUNT(*) FROM employees e
        WHERE e.department = d.department_name) AS employee_count
FROM   departments d;

ใช้ scalar subquery ไม่ใช่ GROUP BY — เพราะ GROUP BY บนตาราง employees จะทำให้แผนกที่ไม่มีพนักงานหายไปทั้งแถว

อีกวิธี: LEFT JOIN … GROUP BY … COUNT(e.employee_id) — ต้องใช้ COUNT(คอลัมน์) ไม่ใช่ COUNT(*) ไม่งั้นจะได้ 1 แทน 0

D13. ขึ้นเงินเดือน: ต่ำกว่า 65,000 ขึ้น 10% ที่เหลือขึ้น 5% ในคำสั่งเดียว
UPDATE employees
SET    salary = CASE WHEN salary < 65000 THEN salary * 1.10
                     ELSE salary * 1.05 END;

ทำไมต้องใช้ CASE — ถ้าเขียนสอง UPDATE แยกกัน คนที่เงินเดือนใกล้เส้นแบ่งอาจถูกขึ้นสองรอบ ขึ้นกับลำดับที่รัน

ENULL & กับดักที่ออกสอบ

E1. WHERE manager_id <> 1 ทำไมพนักงานที่ manager_id เป็น NULL ถึงหายไป

เพราะ NULL <> 1 ให้ผลเป็น UNKNOWN ไม่ใช่ TRUE

และผลของ predicate ใน WHERE ถูกถือว่าเป็น false ถ้าประเมินได้เป็น unknown → แถวนั้นไม่ถูกเลือก

แก้: WHERE manager_id <> 1 OR manager_id IS NULL

E2. เติมตารางความจริง 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

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

E3. คอลัมน์ bonus มี 5 แถว เป็น NULL ทั้งหมด · COUNT(bonus), COUNT(*), SUM(bonus), AVG(bonus) ได้เท่าไหร่

COUNT(bonus) = 0 · COUNT(*) = 5 · SUM(bonus) = NULL · AVG(bonus) = NULL

กฎ: ถ้ากลุ่มมีแต่ค่า null ทั้งหมด → count คืน 0 · aggregate อื่นทั้งหมดคืน null

E4. ทำไม NOT IN (subquery) ถึงอันตรายเมื่อ subquery คืน NULL

x NOT IN (a, b, NULL) เทียบเท่ากับ x <> a AND x <> b AND x <> NULL

x <> NULL ให้ผลเป็น UNKNOWN เสมอ · และ true AND unknown = unknown ผลรวมจึงเป็น UNKNOWN หรือ FALSE เสมอ ไม่มีทางเป็น TRUE → WHERE ไม่เก็บแถวไหนเลย → query คืนผลลัพธ์ว่าง
(แถวที่ค่าตรงกับตัวใดตัวหนึ่งพอดี จะได้ FALSE AND unknown = FALSE ตามตาราง E2 — ไม่ใช่ UNKNOWN แต่ผลลัพธ์สุดท้ายก็คือถูกตัดทิ้งเหมือนกัน)

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

FDatabase Design — ER Model

F1. ระบุว่าแต่ละรายการควรเป็น entity หรือ attribute พร้อมเหตุผล (โจทย์ GlobalMart)

a) Customer → E — มีข้อมูลย่อยหลายอย่างและมี customer ID ไม่ซ้ำ
b) Shipping address → E — มีข้อมูลย่อย และลูกค้าหนึ่งคนมีได้หลายที่
c) Loyalty-points balance → A — เป็นค่าเดียว ไม่มีข้อมูลย่อยลงไปอีก
d) Product Category → E — หมวดหมู่สินค้ามีหลายลำดับชั้น
e) Star rating ของ review → A — เป็นข้อมูลที่ไม่มีค่าย่อยลงมา
f) Review → E — มีทั้ง rating และข้อความ

อย่าลืมเขียนเหตุผล — ข้อสอบให้คะแนนที่เหตุผล ไม่ใช่แค่ตัว E/A

F2. วาด ER diagram Works_In ระหว่าง Employees กับ Departments พร้อมระบุ participation และ cardinality

โจทย์: พนักงานทุกคนต้องสังกัดแผนกเพียงแผนกเดียว แต่บางแผนกอาจไม่มีพนักงานเลยชั่วคราว

ER Works_In

เกณฑ์ให้คะแนน (3 คะแนน):
• มี entity Employees, Departments และ relationship Works_In เชื่อม — 1 คะแนน
• ฝั่ง Employees = total participation (เส้นคู่หรือเส้นทึบ) — 0.5
• ฝั่ง Departments = partial participation (เส้นเดี่ยว) — 0.5
• cardinality ถูก: Employees = 1, Departments = N (0..N) — 1 คะแนน

คำอธิบาย (2 คะแนน): total participation แปลว่าทุกแถวใน entity จะต้องมีส่วนร่วม — พนักงานทุกคนต้องมีแผนกที่ตนเองสังกัด · partial participation แปลว่าบางแถวใน entity อาจไม่มีส่วนร่วม — บางแผนกอาจไม่มีพนักงาน บางแผนกอาจมีคนเดียวหรือมากกว่าหนึ่งคน

F3. Book–Chapter · Chapter เป็น weak entity ไหม เพราะอะไร · cardinality เท่าไหร่

โจทย์: แต่ละเล่มมีหลายบท · เลขบทไม่ซ้ำภายในเล่มเดียวกันแต่ซ้ำได้ข้ามเล่ม · บทมีอยู่ไม่ได้ถ้าไม่มีหนังสือ

ER Book Chapter

รูปในสไลด์วาดไม่ตรงกับสัญลักษณ์ที่เกณฑ์ให้คะแนนต้องการ — ในรูป Chapter วาดเป็นกรอบเส้นประ และ Contains เป็นข้อความบนเส้น ไม่มีข้าวหลามตัด
เวลาสอบให้วาดตามสัญกรณ์ Silberschatz ที่อาจารย์สอน: กรอบสองชั้น = weak entity · ข้าวหลามตัดสองชั้น = identifying relationship · เส้นคู่ = total participation · ขีดเส้นใต้แบบเส้นประ = discriminator (chapter_no)
(ดูรูปที่ถูกต้องได้ที่ บทที่ 11.5)

a) วาด diagram (2 คะแนน): Book, Chapter เป็น entity เชื่อมด้วย relationship เช่น Contains (1) · Chapter ระบุเป็น weak entity ด้วยกรอบสองชั้น (0.5) · cardinality Book (1), Chapter (N) (0.5)

b) เป็น weak entity ไหม (2 คะแนน): เป็น เพราะChapter อยู่เองไม่ได้ ต้องมีหนังสือที่ Chapter นั้นเป็นองค์ประกอบ
เหตุผลเสริม: chapter_no ไม่ซ้ำภายในเล่มเดียวกันแต่ซ้ำข้ามเล่มได้ จึงไม่มี PK ของตัวเอง ต้องพึ่ง book_id

c) cardinality (1 คะแนน): 1 : N

แปลงเป็น schema: Chapter(book_id, chapter_no, title) โดย PRIMARY KEY (book_id, chapter_no)

GER → Relational Schema

G1. เขียนกฎการแปลงทั้งหมด (ตารางสรุป)
ER ComponentMapping
Entityตาราง attribute → คอลัมน์
Simple attribute1 คอลัมน์
Composite attributeแตกเป็นหลายคอลัมน์ (ไม่ใช่ตารางใหม่)
Multi-valued attributeตารางแยกใหม่
1:1FK ฝั่งใดก็ได้ + UNIQUE
1:N / N:1FK ฝั่ง "many"
M:Njunction table ใหม่ PK = FK ทั้งสอง
Ternaryตารางใหม่ FK ทั้งสาม
Total participationFK NOT NULL
Partial participationFK อนุญาต NULL
Weak entityFK → owner + composite PK (owner PK, discriminator)
Relationship attributeอยู่กับ FK (1:N) หรือใน junction table (M:N)
G2. เฉลยเต็ม — แปลง ER คลินิกสัตวแพทย์เป็น schema (20 คะแนน)
ER Diagram คลินิกสัตวแพทย์
CREATE TABLE Owner (
    owner_id INT          PRIMARY KEY,
    name     VARCHAR(100) NOT NULL,
    street   VARCHAR(100),          -- composite → แตกเป็นคอลัมน์
    city     VARCHAR(50),
    zip      VARCHAR(10)
);

CREATE TABLE Pet (
    pet_id   INT         PRIMARY KEY,
    pet_name VARCHAR(50) NOT NULL,
    species  VARCHAR(50),
    owner_id INT         NOT NULL,  -- total participation
    FOREIGN KEY (owner_id) REFERENCES Owner(owner_id)
);

CREATE TABLE Vet (
    vet_id    INT          PRIMARY KEY,
    vet_name  VARCHAR(100) NOT NULL,
    specialty VARCHAR(50)
);

CREATE TABLE Treatment (           -- M:N + relationship attributes
    pet_id     INT,
    vet_id     INT,
    treat_date DATE,
    diagnosis  VARCHAR(255),
    PRIMARY KEY (pet_id, vet_id, treat_date),
    FOREIGN KEY (pet_id) REFERENCES Pet(pet_id),
    FOREIGN KEY (vet_id) REFERENCES Vet(vet_id)
);

CREATE TABLE Vaccination (         -- weak entity ของ Pet
    pet_id       INT,
    dose_no      INT,              -- discriminator
    vaccine_name VARCHAR(50) NOT NULL,
    PRIMARY KEY (pet_id, dose_no),
    FOREIGN KEY (pet_id) REFERENCES Pet(pet_id)
);

จุดหักคะแนนที่พบบ่อย: ใส่ FK แทนการสร้าง Treatment (−4) · สร้างตาราง Address แยก (−2) · Vaccination ใช้ dose_no เดี่ยวเป็น PK หรือสร้าง id ใหม่ (−2) · treat_date ไม่อยู่ใน PK (−2) · ลืม NOT NULL ที่ owner_id (−1)

G3. Student ลงได้หลาย Course และ Course มีนักศึกษาได้หลายคน · เขียน schema

M:N → ต้องสร้าง junction table

CREATE TABLE Student (
    student_id INT PRIMARY KEY,
    name       VARCHAR(100)
);
CREATE TABLE Course (
    course_id   VARCHAR(8) PRIMARY KEY,
    course_name VARCHAR(100)
);
CREATE TABLE Enrollment (
    student_id INT,
    course_id  VARCHAR(8),
    semester   CHAR(1),
    year       YEAR,
    grade      CHAR(2),           -- relationship attribute
    PRIMARY KEY (student_id, course_id, semester, year),
    FOREIGN KEY (student_id) REFERENCES Student(student_id),
    FOREIGN KEY (course_id)  REFERENCES Course(course_id)
);

ทำไมต้องมี semester และ year ใน PK — เพราะนักศึกษาคนเดิมลงวิชาเดิมซ้ำได้ในคนละภาคเรียน (หลักการเดียวกับ treat_date ใน Treatment)

HBad Design — วิเคราะห์ปัญหา

H1. E-Commerce ที่เก็บทุกอย่างในตารางเดียว (repeating groups) มีปัญหาอะไร

Schema ที่แย่: Orders(order_id, customer_name, customer_email, product1_name, product1_price, product2_name, product2_price, …)

order_idcustomer_namecustomer_emailproduct1_nameproduct1_priceproduct2_nameproduct2_priceproduct3_nameproduct3_price
1001Somchaisomchai@gmail.comiPhone 1532000AirPods Pro8900NULLNULL
1002Maleemalee@gmail.comiPhone 1532000NULLNULLNULLNULL
1003Somchaisomchai_new@gmail.comSamsung S2428000NULLNULLNULLNULL
1004Priyapriya@gmail.comLaptop45000Mouse590Keyboard890

ปัญหา 3 ข้อ:

  1. Data redundancy → update anomaly — Somchai ปรากฏในแถว 1001 และ 1003 ด้วยอีเมลต่างกัน ไม่มีทางรู้ว่าอันไหนถูก · ต้องแยกตาราง customers แล้วให้ orders เก็บแค่ customer_id
  2. Repeating groups → จำนวนสินค้าจำกัดตายตัว — Priya ซื้อ 3 ชิ้นพอดี แต่ถ้าเพิ่มชิ้นที่ 4 จะเก็บไม่ได้ ต้องไปแก้โครงสร้างตาราง · ทางแก้คือแยก order_items
  3. ราคาสินค้าถูกเก็บซ้ำ — iPhone 15 ราคา ฿32,000 ปรากฏ 2 แถว ถ้าเปลี่ยนเป็น ฿30,000 ต้องแก้ทุกแถวที่มีสินค้านั้น ถ้ามีแสนออเดอร์ก็ต้องแก้แสนแถว · ต้องแยก products

ตารางที่ควรมี: customers · products · orders · order_items (order_items คือ junction table แบบ M:N ระหว่าง orders กับ products)

H2. โรงพยาบาลที่เก็บประวัติเป็น text blob มีปัญหาอะไร

Schema ที่แย่: Patients(patient_id, name, medical_history_text)

patient_idnamemedical_history_text
P001Wanchai"2024-01: diagnosed diabetes type 2. prescribed metformin 500mg. 2024-06: BP high, added amlodipine 5mg"
P002Nattaya"allergic to penicillin. 2025-03: flu. metformin 1000mg already taken for sugar"
P003Krit"23/Jan/2025 diabetes confirmed. Nurse A updated 10:00. Nurse B updated 10:02 — overwrote Nurse A entry"
  1. Query ไม่ได้อย่างเชื่อถือได้ — “หาผู้ป่วยที่ใช้ metformin ทั้งหมด” ต้องค้นสตริงในข้อความอิสระ ซึ่งพลาดได้ง่ายเพราะรูปแบบการเขียนไม่เหมือนกัน (P002 เขียนว่า “metformin 1000mg already taken for sugar”)
  2. ตรวจสอบข้อมูลซ้ำหรือ drug interaction ไม่ได้ — ระบบไม่มีทางรู้ว่า P002 ได้ยาตัวนี้อยู่แล้ว เพราะข้อมูลไม่ได้อยู่ในรูปแบบมีโครงสร้าง
  3. ไม่มี audit trail — ข้อมูลถูกเขียนทับหาย — P003 แสดงว่าพยาบาล B เขียนทับรายการของพยาบาล A ข้อมูลเดิมหายไปโดยไม่มีร่องรอย

ตารางที่ควรมี: patients · diagnoses · medications · prescriptions · allergies · audit_log

H3. Posts 500 ล้านแถวไม่มี index · ควรสร้าง index อะไร

Query: SELECT * FROM posts WHERE user_id = 12345 ORDER BY created_at DESC;
EXPLAIN บอกว่า type: ALL | rows: 500,000,000 | Extra: Using filesort

อ่านผลของ EXPLAIN ยังไง EXPLAIN = คำสั่งที่ให้ฐานข้อมูลบอกว่ามันวางแผนจะรัน query นี้ยังไง โดยไม่รันจริง
type: ALL = จะสแกนทั้งตาราง (แย่ที่สุด) · rows = คาดว่าต้องอ่านกี่แถว (คอลัมน์นี้ใน EXPLAIN ชื่อ rows ไม่ใช่ rows_examined ซึ่งเป็นของ slow query log) · Using filesort = ต้องเรียงผลลัพธ์เพิ่มอีกรอบหลังดึงข้อมูลมา ซึ่งกินเวลาและหน่วยความจำ

composite index = index ที่สร้างคร่อมหลายคอลัมน์เรียงตามลำดับ เขียน ON posts(a, b) — ใช้กรองด้วย a ก่อน แล้วภายในค่า a เดียวกันข้อมูลก็เรียงตาม b ไว้ให้แล้ว
CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);

ทำไมต้องเป็น composite index สองคอลัมน์ — user_id ช่วยกรอง และ created_at ที่เรียงอยู่แล้วช่วยกำจัด "Using filesort" ทำให้ไม่ต้องเรียงผลลัพธ์ใหม่

สร้างแยกสองตัวก็ช่วยได้บ้าง แต่ composite index ตอบ query นี้ได้ครบทั้งการกรองและการเรียง

ผลกระทบถ้าไม่มี index: full table scan 500 ล้านแถวต่อการโหลด feed หนึ่งครั้ง · ถ้ามีผู้ใช้ 10,000 คนโหลดพร้อมกัน ดิสก์ I/O จะทะลุขีดจำกัด เซิร์ฟเวอร์ค้างและ query queue ยาวขึ้นเรื่อย ๆ

H4. ธนาคารที่ไม่มี transaction · เงินหายไปไหน แก้ยังไง

ตาราง accounts ตอนเริ่มต้น — โจทย์คือโอน ฿3,000 จาก Somying ไป Danai

idownerbalance
1Somying10,000
2Danai5,000

รวมเงินในระบบก่อนโอน = ฿15,000 — ตัวเลขนี้ต้องไม่เปลี่ยนไม่ว่าจะโอนกี่ครั้ง

UPDATE accounts SET balance = balance - 3000 WHERE id = 1;   -- สำเร็จ: Somying เหลือ 7,000
-- เซิร์ฟเวอร์ล่มตรงนี้
UPDATE accounts SET balance = balance + 3000 WHERE id = 2;   -- ไม่เคยถูกรัน

เงิน 3,000 หายไปไหน — คำสั่งแรกทำสำเร็จและถูกบันทึกลงดิสก์แล้ว ส่วนคำสั่งที่สองไม่เคยถูกรัน เงินจึงถูกหักออกจากบัญชีต้นทางโดยไม่เคยเข้าบัญชีปลายทาง · ยอดรวมในระบบลดจาก ฿15,000 เหลือ ฿12,000

SQL ที่ป้องกันได้:

START TRANSACTION;
  UPDATE accounts SET balance = balance - 3000 WHERE id = 1;
  UPDATE accounts SET balance = balance + 3000 WHERE id = 2;
COMMIT;
-- ถ้าล่มก่อน COMMIT ระบบจะ ROLLBACK ให้อัตโนมัติ

Constraint ที่กันยอดติดลบ: CHECK (balance >= 0)

หลักการที่เกี่ยวข้อง: Atomicity — transaction ต้องทำทั้งหมดหรือไม่ทำเลย · นี่คือปัญหาที่ file system แก้ไม่ได้ และเป็นเหตุผลสำคัญที่ต้องใช้ DBMS

H5. KKU Bookstore — หาข้อบกพร่อง 6 ข้อ แล้วเขียน schema ใหม่

schema ที่นักพัฒนามือใหม่เขียนไว้:

CREATE TABLE Customer (
    ID INT PRIMARY KEY,
    FullName VARCHAR(100),           -- "นายสมชาย ใจดี"
    CompleteAddress TEXT,            -- "123 ถ.มิตรภาพ, ขอนแก่น, 40002"
    PhoneNumbers VARCHAR(200),       -- "081-111-2222,043-333-444"
    MemberStatus VARCHAR(50),        -- พิมพ์อะไรลงไปก็ได้
    Age INT,                         -- ตอนนี้มีแถวที่ Age = 250
    RegDate VARCHAR(20)              -- "22/6/2026" เก็บเป็นข้อความ
);
#ปัญหาConcept
1Customer เอกพจน์ · ID, FullName เป็น CamelCasenaming convention
2FullName รวมคำนำหน้า ชื่อ นามสกุลcomposite attribute
3CompleteAddress รวมถนน เมือง รหัสไปรษณีย์composite attribute
4PhoneNumbers เก็บหลายเบอร์คั่นจุลภาคmulti-valued attribute — ละเมิด 1NF
5MemberStatus ไม่จำกัดค่า · Age รับ 250 ได้domain constraint
6RegDate เก็บวันที่เป็น VARCHARdata type
CREATE TABLE customers (
    customer_id       INT PRIMARY KEY,
    name_title        VARCHAR(10),
    first_name        VARCHAR(50) NOT NULL,
    last_name         VARCHAR(50) NOT NULL,
    street_address    VARCHAR(200),
    city              VARCHAR(50),
    postal_code       VARCHAR(10),
    age               INT CHECK (age BETWEEN 16 AND 100),
    member_status     VARCHAR(10) NOT NULL DEFAULT 'regular'
                      CHECK (member_status IN ('regular','silver','gold')),
    registration_date DATE NOT NULL
);

CREATE TABLE customer_phone_numbers (
    phone_id     INT PRIMARY KEY,
    customer_id  INT NOT NULL,
    phone_number VARCHAR(20) NOT NULL,
    phone_type   VARCHAR(10) CHECK (phone_type IN ('mobile','home','office')),
    is_primary   BOOLEAN DEFAULT FALSE,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);

เช็กลิสต์สุดท้ายก่อนเข้าห้องสอบ

5 กับดักที่เสียคะแนนบ่อยที่สุด
  1. NULL ในการเปรียบเทียบ — <> ตัดแถว NULL ทิ้ง · = NULL ใช้ไม่ได้
  2. Π ตัดแถวซ้ำ แต่ SELECT ไม่ตัด — ต้องใส่ DISTINCT เอง
  3. WHERE ใช้ aggregate ไม่ได้ — ต้องใช้ HAVING
  4. M:N ต้องสร้างตารางใหม่ — ใส่ FK เฉย ๆ ไม่ได้ (−4 คะแนน)
  5. เส้นคู่ = NOT NULL · กรอบสองชั้น = composite PK
สูตรที่ต้องจำขึ้นใจ
  • > ANY → > MIN · > ALL → > MAX
  • r ∩ s = r − (r − s)
  • FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
  • candidate key = minimal superkey
  • aggregate ทุกตัวยกเว้น COUNT(*) เมิน NULL
  • weak entity PK = (owner PK, discriminator)