Constraints
ชนิดข้อมูลตัดสินว่า column สามารถเก็บค่าแบบใดได้ ส่วน constraints ตัดสินว่าค่าและชุดค่าผสมใดที่ได้รับอนุญาตจริง ๆ constraint คือกฎที่ server ตรวจทุกครั้งที่ insert และ update แล้วปฏิเสธทุกอย่างที่ละเมิดกฎ เพราะฐานข้อมูลเป็นคนบังคับใช้เอง constraint จึงมีผลเสมอไม่ว่าแอปพลิเคชันหรือคนไหนจะเป็นผู้เขียนข้อมูล นี่คือกระดูกสันหลังของความสมบูรณ์ของข้อมูล
เราจะจำลองร้านค้าจิ๋ว ๆ ที่มี customers และ orders เพื่อดู constraint แต่ละชนิดทำงาน
NOT NULL และ DEFAULT
หัวข้อที่มีชื่อว่า “NOT NULL และ DEFAULT”constraint แบบ NOT NULL ห้ามการไม่มีค่า ดังนั้น column นั้นต้องถูกเติมเสมอ ส่วน DEFAULT จัดหาค่าให้เมื่อการ insert ละเว้น column นั้นไป ทั้งคู่จับคู่กันได้อย่างเป็นธรรมชาติ: default ที่สมเหตุสมผลบวกกับ NOT NULL หมายความว่า column นั้นมีค่าอยู่เสมอโดยไม่ต้องบังคับให้ทุกการ insert ระบุค่าเอง
CREATE TABLE customers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());ที่นี่ name ต้องถูกระบุเสมอ ขณะที่ created_at จะเติมตัวเองด้วยช่วงเวลาปัจจุบันเว้นแต่คุณจะจัดหาให้
PRIMARY KEY
หัวข้อที่มีชื่อว่า “PRIMARY KEY”PRIMARY KEY ทำเครื่องหมาย column (หรือหลาย column) ที่ระบุ row ได้อย่างไม่ซ้ำกัน ถือเป็นรูปแบบย่อของ NOT NULL บวก UNIQUE และ table หนึ่งมีได้เพียงตัวเดียว column id ข้างบนคือ primary key ของ customers
constraint แบบ UNIQUE ห้ามค่าที่ซ้ำกันใน column แต่ยังยอมให้มี null ได้ นี่คือวิธีเขียนกฎอย่าง “ลูกค้าสองคนใช้ email ร่วมกันไม่ได้” โดยไม่ทำให้ column นั้นเป็น primary key
ALTER TABLE customers ADD COLUMN email text, ADD CONSTRAINT customers_email_unique UNIQUE (email);การตั้งชื่อ constraint ว่า customers_email_unique นั้นจะทำหรือไม่ก็ได้ แต่คุ้มมาก เพราะข้อความ error และคำสั่ง ALTER TABLE ทีหลังจะอ้างถึงด้วยชื่อนี้ แทนที่จะเป็นป้ายที่ระบบสร้างให้อัตโนมัติ
constraint แบบ CHECK ทดสอบนิพจน์กับแต่ละ row และปฏิเสธการเขียนถ้านิพจน์ประเมินได้เป็น false ใช้เขียนกฎทางธุรกิจที่ชนิดเพียงอย่างเดียวแสดงไม่ได้ เช่น ราคาที่ต้องเป็นบวก
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_id bigint NOT NULL, total numeric(10, 2) NOT NULL, CONSTRAINT orders_total_positive CHECK (total > 0));ความพยายามใด ๆ ที่จะ insert order ที่มี total เป็นศูนย์หรือค่าลบ จะล้มเหลวก่อนที่ row นั้นจะถูกเก็บ
FOREIGN KEY
หัวข้อที่มีชื่อว่า “FOREIGN KEY”FOREIGN KEY ผูก column เข้ากับคีย์ในอีก table หนึ่ง รับประกันว่าทุก customer_id ใน orders ชี้ไปยัง row จริงใน customers นี่คือ referential integrity: ฐานข้อมูลจะไม่ยอมให้คุณสร้าง order สำหรับลูกค้าที่ไม่มีอยู่จริง และจะไม่ปล่อยให้ order ชี้ไปยังลูกค้าที่คุณลบไปแล้ว
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE;clause ON DELETE ตัดสินว่าจะเกิดอะไรขึ้นกับ row ที่ขึ้นต่อกันเมื่อ row ที่ถูกอ้างถึงถูกลบ CASCADE จะลบ order ที่ตรงกันไปพร้อมกับลูกค้า ขณะที่ RESTRICT จะปิดกั้นการลบตราบเท่าที่ยังมี order ใดอ้างถึงลูกค้ารายนั้นอยู่ เลือก CASCADE เมื่อ row ลูกไม่มีความหมายหากไม่มี row แม่ และเลือก RESTRICT เมื่อคุณต้องการการ์ดที่ตั้งใจไว้เพื่อกันการสูญหายโดยอุบัติเหตุ
ตอนนี้ทั้งสอง table สัมพันธ์กันแบบนี้ โดยแต่ละ order ชี้กลับไปยังลูกค้าหนึ่งรายพอดี
flowchart LR
subgraph customers
C1[id PK]
C2[name]
C3[email UNIQUE]
end
subgraph orders
O1[id PK]
O2[customer_id FK]
O3[total]
end
O2 -->|REFERENCES| C1 รันจาก driver
หัวข้อที่มีชื่อว่า “รันจาก driver”constraints อยู่ใน DDL แอปพลิเคชันจึงมักเพิ่มผ่าน migration คำสั่งนี้คือ SQL เดียวกับที่คุณจะพิมพ์ใน psql เพียงแต่ส่งผ่าน driver เท่านั้น
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE;await pool.query(` ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE`);cur.execute(""" ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE""")_, err := conn.Exec(ctx, ` ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE`)sqlx::query( "ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE",).execute(&pool).await?;ใน pgAdmin: constraints ของ table จะปรากฏใต้โหนด Constraints ของ table นั้นในแผนผัง browser และ dialog Properties มีแท็บสำหรับ primary key, foreign key, check และ unique constraint หากคุณชอบใช้ฟอร์มมากกว่า SQL
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- foreign key บังคับใช้ referential integrity ให้คุณ ดังนั้นฐานข้อมูลจะไม่มีวันถือ order สำหรับลูกค้าที่ไม่มีอยู่จริง การพึ่งพาโค้ดของแอปพลิเคชันเพียงอย่างเดียวเพื่อรักษาความถูกต้องของการอ้างอิงจะล้มเหลวในที่สุด
- ตั้งชื่อ constraints ของคุณให้ชัดเจน อย่าง
orders_total_positiveแทนที่จะยอมรับชื่อที่สร้างขึ้นอัตโนมัติ ชื่อที่ชัดเจนทำให้ข้อความ error อ่านได้และทำให้การเปลี่ยนแปลงALTER TABLEในภายหลังเป็นเรื่องตรงไปตรงมา - เลือกพฤติกรรม
ON DELETEอย่างตั้งใจCASCADEทำความสะอาด row ที่ขึ้นต่อกันโดยอัตโนมัติแต่อาจลบมากกว่าที่คุณคาด ส่วนRESTRICTคือค่าเริ่มต้นที่ปลอดภัยกว่าเมื่อคุณไม่แน่ใจ - constraint แบบ
CHECKสามารถอ้างถึงหลาย column ของ row เดียวกันได้ ดังนั้นคุณสามารถเข้ารหัสกฎอย่างส่วนลดที่ต้องไม่เกิน total แต่มอง row อื่นหรือ table อื่นไม่ได้ - การเพิ่ม constraint ลงใน table ที่มีข้อมูลอยู่แล้วจะล้มเหลวถ้ามี row เดิมละเมิดกฎอยู่ ทำความสะอาดข้อมูลก่อน แล้วจึงเพิ่ม constraint
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
บังคับ invariant ด้วย constraint ในฐานข้อมูล (CHECK, UNIQUE, FOREIGN KEY) | ถูกต้องเสมอไม่ว่า client หรือ service ไหนเขียนข้อมูล | เปลี่ยนกฎทีหลังต้องรัน migration และอาจกระทบ row เดิม |
| validate เฉพาะใน application code | เปลี่ยนกฎได้เร็ว ไม่ต้องแตะ schema | ข้อมูลเสียหลุดเข้ามาได้ง่ายผ่านสคริปต์ตรง ๆ, service อื่น หรือ migration ที่ลืมเช็ค |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- พึ่งพา validation ระดับแอปพลิเคชันอย่างเดียว — สคริปต์ ad-hoc, batch job หรือ service อื่นที่เขียนตรงเข้า database สามารถข้าม validation ของแอปได้เสมอ ต้องมี constraint ระดับฐานข้อมูลเป็นด่านสุดท้าย
- ลืมกำหนดพฤติกรรม
ON DELETEของ foreign key — โดย default foreign key จะเป็นON DELETE NO ACTION(เทียบเท่าRESTRICT) ซึ่ง block การลบ parent row ที่ยังถูกอ้างถึงอยู่ และ raise foreign-key violation ออกมา ไม่ปล่อยให้เกิด orphaned row ดังนั้นความเสี่ยงจริงคือการเผลอเลือกON DELETE CASCADEแล้วลบข้อมูลลูกเกินคาด ส่วน orphaned row ที่ชี้ไปยังของที่ไม่มีอยู่แล้วจะเกิดได้ก็ต่อเมื่อเผลอใช้ON DELETE SET NULLโดยไม่ได้ตั้งใจ หรือ bypass constraint ไปเท่านั้น - เพิ่ม
UNIQUEconstraint บน table ที่เขียนหนักโดยไม่คิดเรื่อง index —UNIQUEสร้าง index ให้อัตโนมัติ ซึ่งเพิ่มต้นทุนทุกครั้งที่ insert/update บน table ที่มี write volume สูง ต้องวางแผนล่วงหน้า
💡 ตัวอย่างจากของจริง
แพลตฟอร์ม e-commerce — มักใช้
CHECK (price >= 0)และ foreign key ระหว่าง orders กับ customers ที่ระดับฐานข้อมูล เพื่อไม่ให้ web app, batch job หรือสคริปต์ของแอดมินตัวไหนเขียนข้อมูลที่ไม่สอดคล้องกันเข้าไปได้เลยGitLab — มีแนวทาง schema review ที่กำหนดให้ column แบบ relational แทบทุกตัวต้องมี foreign key constraint กำกับ เพื่อรักษา referential integrity ของฐานข้อมูลขนาดใหญ่ไว้ในระยะยาว