Advanced Relational Concepts & Schema Design
การตั้งชื่อแบบ snake_case · composite attribute · multi-valued attribute · domain และ CHECK constraint
4.1Naming Convention — snake_case vs CamelCase
อาจารย์ตั้งกติกาของวิชานี้ไว้ชัดเจนว่าให้ใช้ snake_case พร้อมเหตุผลที่ตอบได้ในข้อสอบแนวอธิบาย
street_address
- Universal compatibility — ทำงานเหมือนกันทุกระบบฐานข้อมูล
- อ่านง่ายกว่า —
employee_performance_review_dateแยกคำได้ทันที - ตรงกับมาตรฐาน SQL — เข้าชุดกับฟังก์ชันในตัวอย่าง
CURRENT_TIMESTAMP - Case-insensitive — ไม่มีปัญหาตอน deploy หรือย้ายระบบ
- มาตรฐานทั้งวงการและตำรา
streetAddress
- ปัญหา case sensitivity — MySQL ทำงานต่างกันบน Linux กับ Windows
- PostgreSQL แปลงเป็นตัวพิมพ์เล็กอัตโนมัติ ถ้าไม่ใส่เครื่องหมายคำพูดครอบ
- อ่านยากกว่า —
employeePerformanceReviewDateต้องเพ่ง - ขัดกับธรรมเนียมของภาษา SQL เอง
- เสี่ยงพังตอนย้ายสภาพแวดล้อม
| สิ่งที่ตั้งชื่อ | กติกา | ทำไม (ตอบข้อสอบแนวอธิบายได้) |
|---|---|---|
| ตาราง | คำนามพหูพจน์ → customers, orders |
ตารางเก็บหลายแถว · SELECT * FROM customers จึงอ่านเป็นประโยคภาษาอังกฤษได้ตรงความหมาย |
| Primary key | table_name_id → customer_id |
เห็นชื่อคอลัมน์ก็รู้ทันทีว่ามาจากตารางไหน — สำคัญมากตอน join หลายตารางแล้วมีคอลัมน์ id โผล่มาเต็มไปหมด |
| Foreign key | ใช้ชื่อเดียวกับ PK ที่มันอ้างถึง → orders ก็ใช้ customer_id |
เงื่อนไข join อ่านเป็น orders.customer_id = customers.customer_id ได้เลย
และใช้ USING (customer_id) หรือ NATURAL JOIN ได้ ซึ่งจะสั้นกว่ามาก |
| Attribute ทั่วไป | snake_case ที่สื่อความหมาย → first_name |
คนอ่านโค้ดต่อไม่ต้องเปิด documentation ก็เดาได้ว่าคอลัมน์เก็บอะไร |
| วันที่ | จุดประสงค์ + _date → registration_date |
ตารางเดียวมักมีหลายวันที่ (created_date, shipped_date) — ถ้าตั้งว่า date เฉย ๆ จะแยกไม่ออกว่าวันอะไร |
| Boolean | ขึ้นต้นด้วย is_ / has_ → is_active |
อ่าน WHERE is_active ได้เป็นประโยคจริง/เท็จทันที · ถ้าตั้งว่า active เฉย ๆ จะเดาไม่ออกว่าเก็บ true/false หรือเก็บวันที่ |
| ชื่อฐานข้อมูล | ตัวพิมพ์เล็ก + ขีดล่าง ไม่มีเว้นวรรค | เว้นวรรคและอักขระพิเศษบังคับให้ต้องใส่เครื่องหมายคำพูดครอบทุกครั้ง และมักพังตอนเขียน script หรือ backup |
4.2Composite Attribute
Composite attribute คือ attribute ที่มีส่วนประกอบย่อยซึ่งมีความหมายในตัวเอง เช่น ที่อยู่ประกอบด้วยถนน เมือง รหัสไปรษณีย์ และประเทศ ซึ่งแต่ละส่วนอาจถูกค้นหาหรือเรียงลำดับแยกกันในแอปพลิเคชันจริง
วิธีจัดเก็บส่งผลโดยตรงต่อความสามารถในการ query — ถ้าเก็บ "123 Main St, Bangkok, 10110" เป็นช่องเดียว
เราจะค้นตามเมืองไม่ได้ และเรียงตามรหัสไปรษณีย์ไม่ได้อย่างมีประสิทธิภาพ
employees(name_title, first_name, middle_name, last_name, name_suffix)เก็บรวมได้ เมื่อแทบไม่ query แยก ใช้แค่แสดงผล →
contacts(contact_id, full_name) เก็บ "Dr. John Smith Jr." ทั้งก้อนข้อควรคำนึงระดับสากล: รูปแบบที่อยู่ต่างกันไปในแต่ละประเทศ การออกแบบ composite attribute จึงต้องรองรับโครงสร้างที่อยู่ตามวัฒนธรรมและกฎหมายที่ต่างกัน
4.3Multi-valued Attribute
Multi-valued attribute ละเมิด First Normal Form — relational model บังคับว่าค่าต้องเป็น atomic การที่พนักงานหนึ่งคนมีเบอร์โทรได้หลายเบอร์จึงเป็นโจทย์ออกแบบพื้นฐานที่ต้องแก้ให้ถูก
phone_numbers VARCHAR(500) = '555-1234,555-5678'→ ค้นหาเบอร์ใดเบอร์หนึ่งยากมาก, สร้าง index ไม่ได้, ลบเบอร์เดียวออกต้องเขียนโค้ดตัดสตริง, ตรวจความถูกต้องไม่ได้
index = โครงสร้างค้นหาที่ฐานข้อมูลสร้างไว้ล่วงหน้าบนคอลัมน์หนึ่ง เหมือนดัชนีท้ายเล่มหนังสือ ทำให้หาแถวได้โดยไม่ต้องไล่อ่านทุกแถว — แต่มันทำงานกับค่าทั้งช่อง ไม่ใช่ “ส่วนหนึ่งของสตริง” พอเก็บเป็น
'555-1234,555-5678' ก็ index ไม่ได้ (รายละเอียดในบทที่ 10)2. ทำคอลัมน์ phone_1, phone_2, phone_3
→ เปลืองที่ (ส่วนใหญ่ว่าง), จำกัดจำนวนแบบไร้เหตุผล (แล้วถ้ามีเบอร์ที่ 4?), และตรรกะ query ซับซ้อนเมื่อต้องค้นข้ามทุกคอลัมน์เบอร์
ทางแก้ที่ถูกคือ normalization — สร้างตารางแยกที่มี employee_id และ phone_number
ซึ่งรองรับเบอร์โทรได้ไม่จำกัดต่อพนักงานหนึ่งคน
และตารางแบบนี้มักมี attribute เพิ่มเติมนอกเหนือจากความสัมพันธ์พื้นฐาน เช่น phone_type (mobile/office/home)
หรือธง is_primary เพื่อรองรับความต้องการทางธุรกิจให้ครบ
4.4Integrity Constraint ที่ซับซ้อนขึ้น
เป้าหมายคือการบังคับกฎทางธุรกิจที่ระดับฐานข้อมูล ไม่ใช่ฝากไว้ในโค้ดแอปพลิเคชัน
- Domain constraint จำกัดค่าที่ยอมรับได้ เช่น เกรดต้องอยู่ใน
('A','B','C','D','F')หรืออายุนักศึกษาต้องอยู่ระหว่าง 16–100 ป้องกันการป้อนข้อมูลผิด
ขอบเขตต้องมาจากกฎธุรกิจจริง ไม่ใช่ตัวเลขสวย ๆ — 16 = อายุขั้นต่ำที่เป็นนักศึกษาได้ · 100 = เกินนี้แทบแน่นอนว่าพิมพ์ผิด - CHECK constraint ใช้ตรรกะทางธุรกิจ เช่น วันสิ้นสุดต้องมาหลังวันเริ่มต้น เงินเดือนต้องเป็นบวก หรือส่วนลดต้องอยู่ในช่วงที่สมเหตุสมผล
CREATE TABLE students (
student_id INT PRIMARY KEY,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
student_age INT CHECK (student_age >= 16 AND student_age <= 100),
current_grade ENUM('A', 'B', 'C', 'D', 'F'),
grade_point_average DECIMAL(3,2)
CHECK (grade_point_average >= 0.0 AND grade_point_average <= 4.0),
email_address VARCHAR(100) CHECK (email_address LIKE '%@%.%'),
enrollment_date DATE NOT NULL,
graduation_date DATE,
CONSTRAINT check_graduation_after_enrollment
CHECK (graduation_date IS NULL OR graduation_date > enrollment_date)
);
| เขียนแบบ | แปลว่า |
|---|---|
| ENUM('A','B','C','D','F') | ชนิดข้อมูลที่ยอมรับได้แค่ค่าในลิสต์เท่านั้น — ใส่ 'E' ลงไปจะถูกปฏิเสธ
(SQLite ไม่มี ENUM ต้องใช้ CHECK (col IN (…)) แทน) |
| email_address LIKE '%@%.%' | LIKE คือการเทียบรูปแบบของข้อความ · % = “ตัวอักษรอะไรก็ได้ กี่ตัวก็ได้ รวมทั้งไม่มีเลย”อ่านทีละส่วน: % อะไรก็ได้ → @ ต้องมี @ → % อะไรก็ได้ → . ต้องมีจุด → % อะไรก็ได้ผ่าน: a@kku.ac.th ·
ไม่ผ่าน: akku.ac.th (ไม่มี @) และ a@kku (ไม่มีจุดหลัง @)เป็นการตรวจแบบหยาบ ๆ เท่านั้น — @. ก็ยังผ่าน แต่ดีกว่าไม่ตรวจเลย |
| CONSTRAINT ชื่อ CHECK (…) | constraint ระดับตาราง แบบตั้งชื่อเอง — ต้องเขียนแยกไว้ท้ายตาราง เพราะเงื่อนไขนี้อ้างถึง
คอลัมน์ 2 ตัวพร้อมกัน (graduation_date กับ enrollment_date)
จึงเขียนต่อท้ายคอลัมน์ใดคอลัมน์หนึ่งไม่ได้การตั้งชื่อ ( check_graduation_after_enrollment) ทำให้ข้อความ error บอกได้ว่ากฎข้อไหนพัง
และสั่ง ALTER TABLE … DROP CONSTRAINT ชื่อ ทีหลังได้ — ถ้าไม่ตั้งชื่อ ระบบจะตั้งให้เองเป็นชื่อมั่ว ๆ อย่าง students_chk_1 |
CREATE TABLE ใช้เมื่อเงื่อนไขข้ามหลายคอลัมน์ (หรือเมื่ออยากตั้งชื่อ)
DECIMAL(3,2) สำหรับ GPA — 3 หลักรวม ทศนิยม 2 ตำแหน่ง เก็บ 4.00 ได้พอดี และไม่มีปัญหาความคลาดเคลื่อนแบบ FLOAT2.
graduation_date IS NULL OR graduation_date > enrollment_date — ต้องมี IS NULL นำหน้า
ไม่งั้นนักศึกษาที่ยังไม่จบ (graduation_date เป็น NULL) จะ insert ไม่ผ่าน เพราะ NULL > date ให้ผลเป็น UNKNOWN ไม่ใช่ TRUE
4.5หลักการออกแบบ Schema ที่ดี
ตั้งชื่อตาราง คอลัมน์ และ constraint ให้สม่ำเสมอและสื่อความหมาย ทำให้ฐานข้อมูลอธิบายตัวเองได้ และลดข้อผิดพลาดตอนดูแลรักษา
เก็บข้อมูลที่จำเป็นให้ครบ แต่อย่า over-engineer จนงานที่ทำบ่อยกลายเป็นเรื่องยุ่งยาก
มาตรฐานการตั้งชื่อ ชนิดข้อมูล และรูปแบบการออกแบบที่เป็นแบบเดียวกัน ทำให้นักพัฒนาหลายคนทำงานบนฐานข้อมูลเดียวกันได้อย่างมีประสิทธิภาพ
การออกแบบที่ดีคาดการณ์เส้นทางการเติบโตที่เป็นไปได้ ทำให้เพิ่มฟีเจอร์ใหม่ได้โดยไม่ต้องรื้อโครงสร้างใหญ่
การเลือกชนิดข้อมูล
| ประเด็น | หลักการ |
|---|---|
| ความยาวสตริง | VARCHAR(50) กับ VARCHAR(255) ส่งผลต่อทั้งพื้นที่และประสิทธิภาพ — ประเมินค่าสูงสุดที่เป็นไปได้จริงบวกเผื่อการเติบโต |
| ความแม่นยำเชิงตัวเลข | DECIMAL รับประกันความถูกต้องของการคำนวณเงิน · FLOAT ให้ประสิทธิภาพดีกว่าสำหรับงานวิทยาศาสตร์ที่ยอมรับความคลาดเคลื่อนเล็กน้อยได้ |
| ความละเอียดของวันเวลา | DATE สำหรับวันเกิด · DATETIME สำหรับ timestamp ของธุรกรรม · พิจารณา time zone สำหรับแอประดับสากล |
| ค่าที่มีชุดจำกัด | ENUM หรือ lookup table + FK บังคับค่าสถานะที่ถูกต้อง พร้อมช่วยเรื่องประสิทธิภาพ |
| ขนาดของจำนวนเต็ม | INT รองรับได้ราว 2 พันล้าน · BIGINT รองรับมากกว่ามากแต่กินพื้นที่มากกว่า |
Design pattern ที่เจอบ่อย
ประเทศ จังหวัด หมวดหมู่สินค้า และข้อมูลอ้างอิงที่ค่อนข้างนิ่ง ควรแยกออกเป็นตารางอ้างอิงต่างหาก
เพิ่ม created_by, created_date, modified_by, modified_date เพื่อติดตามการเปลี่ยนแปลง
ใช้ธง is_deleted แทนการลบจริง เพื่อรักษา referential integrity พร้อมซ่อนเรคอร์ดที่ไม่ใช้แล้ว
กระบวนการอย่างการประมวลผลคำสั่งซื้อหรือการอนุมัติ ต้องออกแบบฟิลด์สถานะและกฎการเปลี่ยนสถานะอย่างรอบคอบ
ออกแบบเผื่อประสิทธิภาพตั้งแต่ต้น
- วางแผน index ตั้งแต่ตอนออกแบบ — ระบุว่าคอลัมน์ไหนจะถูกค้น เรียง หรือ join บ่อย (index = โครงสร้างค้นหาที่สร้างไว้ล่วงหน้า ทำให้ค้นเร็วขึ้นมากแต่ทำให้ INSERT/UPDATE ช้าลง — บทที่ 10)
- วิเคราะห์รูปแบบ query — ระบบอ่านหนักหรือเขียนหนัก มีผลต่อการตัดสินใจเรื่อง normalization และการออกแบบเชิงกายภาพ
- Denormalization มีข้อแลกเปลี่ยน — จงใจเก็บข้อมูลซ้ำไว้ในตารางเดียว (เช่น เก็บ
customer_nameไว้ในตาราง orders ด้วย) เพื่อจะได้ไม่ต้อง join ตอนอ่าน · เร็วขึ้นก็จริง แต่แลกกับต้องคอยอัปเดตหลายที่ให้ตรงกัน ซึ่งคือปัญหาที่ normalization แก้ไว้ตั้งแต่แรก - ชนิดข้อมูลที่พอดี ลดพื้นที่จัดเก็บและทำให้ memory cache ทำงานได้ผลดีขึ้น
4.6แบบฝึกหัดในคาบ — KKU Bookstore
โจทย์: นักพัฒนามือใหม่ออกแบบฐานข้อมูลร้านหนังสือ KKU ไว้แบบนี้ ให้หาข้อบกพร่อง 6 ข้อ แล้วบอกว่าเป็นปัญหาเรื่องอะไร
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" เก็บเป็นข้อความ
);
เฉลย Part A — ข้อบกพร่อง 6 ข้อ พร้อมชื่อ concept
| # | ปัญหา | Concept |
|---|---|---|
| 1 | Customer ใช้เอกพจน์และ ID, FullName เป็น CamelCase | naming convention — ควรเป็น customers, customer_id, full_name |
| 2 | FullName รวมคำนำหน้า ชื่อ นามสกุลไว้ช่องเดียว | composite attribute — ค้นหา/เรียงตามนามสกุลไม่ได้ |
| 3 | CompleteAddress รวมถนน เมือง รหัสไปรษณีย์ | composite attribute — ค้นตามเมืองหรือเรียงตามรหัสไปรษณีย์ไม่ได้ |
| 4 | PhoneNumbers เก็บหลายเบอร์คั่นด้วยจุลภาค | multi-valued attribute — ละเมิด 1NF ต้องแยกตาราง |
| 5 | MemberStatus ไม่มีการจำกัดค่า พิมพ์อะไรก็ได้ · Age รับค่า 250 ได้ | domain constraint — ขาด CHECK / ENUM |
| 6 | RegDate เก็บวันที่เป็น VARCHAR | data type — เรียงลำดับผิด คำนวณช่วงวันไม่ได้ ตรวจความถูกต้องไม่ได้ |
เฉลย Part B — schema ที่แก้แล้ว
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 >= 16 AND age <= 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)
);
หมายเหตุเรื่อง portability: SQLite ไม่รองรับ ENUM จึงต้องใช้ CHECK (col IN (...)) แทน —
ซึ่งเป็นบทเรียนที่ดีว่าการเขียนแบบ standard SQL ย้ายระบบได้ง่ายกว่า
เฉลย Part C — พิสูจน์ว่า constraint ทำงานจริง
-- 1. แถวที่ถูกต้อง → สำเร็จ
INSERT INTO customers VALUES
(1,'นาย','สมชาย','ใจดี','123 ถ.มิตรภาพ','ขอนแก่น','40002',
22,'gold','2026-06-22');
-- 2. age = 250 → ต้องล้มเหลว
INSERT INTO customers VALUES
(2,'นาง','มาลี','สุขใจ','1 ถ.ศรีจันทร์','ขอนแก่น','40000',
250,'regular','2026-06-22');
-- CHECK constraint failed: age
-- 3. member_status = 'platinum' → ต้องล้มเหลว
INSERT INTO customers VALUES
(3,'นาย','กฤต','ตั้งใจ','9 ถ.กลางเมือง','ขอนแก่น','40000',
30,'platinum','2026-06-22');
-- CHECK constraint failed: member_status
4.7สรุปบทนี้
- snake_case คือมาตรฐานวิชาชีพ — เข้ากันได้ทุกระบบและไม่มีปัญหา case sensitivity
- แตก composite attribute เมื่อส่วนย่อยถูก query แยกกัน — ที่อยู่ ชื่อ ควรแยกเมื่อต้องค้นหรือเรียงทีละส่วน
- วิธีจัดเก็บกำหนดความสามารถในการ query โดยตรง — ช่องเดียวจำกัดตัวเลือกการค้นหา คอลัมน์แยกเปิดให้ query ได้ยืดหยุ่น
- เลี่ยงฝันร้ายในการดูแลรักษา — อย่าใช้ค่าคั่นจุลภาคหรือคอลัมน์จำกัดจำนวนแบบ phone_1, phone_2, phone_3
- ใช้ domain และ check constraint ให้เป็นประโยชน์ — กันอายุ เกรด วันที่ที่ไม่ถูกต้อง และรักษาความสัมพันธ์เชิงตรรกะทางธุรกิจ
phone_numbers VARCHAR(500) = '081-111-2222,043-333-444' ละเมิดหลักการใดemployee_phone_numbers ที่ผูกด้วย FKCHECK (graduation_date IS NULL OR graduation_date > enrollment_date) ต้องมีส่วน IS NULLNULL > '2026-01-01' ให้ผลเป็น UNKNOWN ไม่ใช่ TRUE · CHECK จะปฏิเสธแถวเมื่อผลไม่เป็นจริง จึงต้องเผื่อกรณี NULL ไว้ด้วย OR — นี่คือตัวอย่างที่ตรรกะ 3 ค่าโผล่มาในเรื่องออกแบบ (ดูบทที่ 7)