การออกแบบ Schema
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ก่อนจะเขียน SQL สักบรรทัด เราออกแบบรูปร่างของข้อมูลก่อน TaskFlow เป็นบอร์ด Kanban แบบเรียลไทม์ ดังนั้น data model จึงต้องเก็บทั้งผู้ใช้ (users), บอร์ดที่พวกเขาเป็นเจ้าของ, ใครบ้างที่มองเห็นแต่ละบอร์ด, คอลัมน์บนบอร์ด, การ์ดภายในคอลัมน์เหล่านั้น และ label ที่ใช้ติดแท็กให้การ์ด
รวมทั้งหมดเป็นเจ็ดตาราง นี่คือภาพรวมของทั้งโมเดล:
erDiagram users ||--o{ boards : "owns" users ||--o{ board_members : "joins" boards ||--o{ board_members : "has" boards ||--o{ columns : "contains" boards ||--o{ labels : "defines" columns ||--o{ cards : "contains" cards ||--o{ card_labels : "tagged by" labels ||--o{ card_labels : "applied to"
users { uuid id PK text email UK text password_hash text display_name timestamptz created_at } boards { uuid id PK uuid owner_id FK text title timestamptz created_at } board_members { uuid board_id PK,FK uuid user_id PK,FK text role } columns { uuid id PK uuid board_id FK text title double position } cards { uuid id PK uuid column_id FK text title text description double position timestamptz created_at } labels { uuid id PK uuid board_id FK text name text color } card_labels { uuid card_id PK,FK uuid label_id PK,FK }ไดอะแกรมนี้คือแหล่งข้อมูลหลัก (single source of truth) สำหรับคอร์สที่เหลือทั้งหมด ทุกโมดูลต่อจากนี้ — migrations, REST API, authentication, ชั้นเรียลไทม์ และ frontend — ล้วนอ้างอิงกลับมาที่ตารางและคอลัมน์ชุดนี้แบบเป๊ะ ๆ ถ้าออกแบบตรงนี้ให้ถูก ส่วนที่เหลือของโปรเจกต์ก็จะมีพื้นฐานที่มั่นคง
schema คือชุดของคำสัญญา บอกว่า เอนทิตีเหล่านี้มีอยู่จริง แต่ละตัวถือฟิลด์อะไรบ้าง และมีกฎอะไรที่คอยรักษาให้ข้อมูลถูกต้อง schema ที่ดีจะผลักการรับประกันความถูกต้องให้ลงไปอยู่ในฐานข้อมูลให้มากที่สุด เพราะตรงนั้นคือที่ที่กฎเหล่านั้นมีผลเสมอ ไม่ว่าโค้ดส่วนไหนจะเป็นคนเขียนข้อมูลลงไป
โมเดลของ TaskFlow เป็นลำดับชั้น (hierarchy) แบบคลาสสิก บวกกับตาราง join แบบ many-to-many สองตาราง:
- user เป็นเจ้าของ boards หลายบอร์ด (one-to-many)
- board มี members หลายคนผ่านตาราง join
board_members(many-to-many ระหว่าง users กับ boards) - board มี columns หลายคอลัมน์ และ column มี cards หลายการ์ด — นี่คือแกนลำดับชั้นของ Kanban
- board นิยาม labels ได้หลายอัน และ card ติด label ได้หลายอันผ่านตาราง join
card_labels(many-to-many ระหว่าง cards กับ labels)
มีการตัดสินใจสองอย่างที่ตัดผ่านทุกตาราง: เราใช้ UUID primary key แทนเลขจำนวนเต็มที่เพิ่มอัตโนมัติ และเราพึ่งพา foreign key พร้อม ON DELETE CASCADE เพื่อให้การลบแถวแม่ลบทุกอย่างที่ห้อยอยู่ข้างใต้ออกไปอย่างสะอาด ทั้งสองเรื่องอธิบายเต็ม ๆ ด้านล่าง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”UUID primary key (สิ่งที่เราใช้)
- ข้อดี: ไม่ซ้ำกันทั่วโลก ทำให้สร้าง ID ได้ทั้งฝั่ง client, ใน API หรือในฐานข้อมูลโดยไม่ต้องประสานงานกัน; ไม่มีการชนกันข้ามตาราง; ไม่รั่วข้อมูลเชิงธุรกิจ (ค่า
idที่เป็นเลข4207แอบบอกโลกว่าคุณมีกี่แถว และให้ใครก็ตามเดา/boards/4208ได้); การรวมข้อมูลจากหลายแหล่งหรือทำ sharding ในภายหลังทำได้ง่ายเพราะ ID ไม่มีวันชนกัน - ข้อเสีย: ใช้ 16 ไบต์แทนที่จะเป็น 4 หรือ 8 ทำให้ index ใหญ่ขึ้น; UUID แบบสุ่มกระจาย insert ไปทั่ว B-tree แทนที่จะต่อท้าย ซึ่งอาจทำให้ write locality แย่ลง; และอ่านหรือพิมพ์ยากตอน debug
bigserial / เลขจำนวนเต็มเพิ่มอัตโนมัติ (ทางเลือก)
- ข้อดี: กะทัดรัด (8 ไบต์), เพิ่มขึ้นต่อเนื่องจึง insert แบบต่อท้าย index (write locality ดีเยี่ยม), และดูด้วยตาง่าย
- ข้อเสีย: ค่าเดาและไล่ลำดับได้ (ความเสี่ยง information-disclosure และ IDOR บน public API), ต้องมี round-trip หรือ sequence เพื่อสร้างค่า, และชนกันทันทีที่คุณพยายามรวมสองฐานข้อมูลหรือทำ sharding
สำหรับแอปที่ทำงานร่วมกันแบบเรียลไทม์ ซึ่ง client มักอยากสร้างการ์ดและรู้ ID ทันที — ก่อนที่ round-trip ไปเซิร์ฟเวอร์จะเสร็จ — UUID เหมาะกว่า เรายอมรับ index ที่ใหญ่ขึ้นเล็กน้อยเพื่อแลกกับอิสระในการทำงาน
Foreign key พร้อม ON DELETE CASCADE
- ข้อดี: ฐานข้อมูลบังคับ referential integrity — คุณ insert การ์ดที่ชี้ไปยังคอลัมน์ที่ไม่มีอยู่จริงไม่ได้เลย — และ cascade delete ช่วยรักษาข้อมูลให้เรียบร้อยโดยไม่ต้องมีคิวรีล้างข้อมูลแบบมือกองพะเนิน
- ข้อเสีย: cascade อาจลบมากกว่าที่คุณคาดถ้าไม่ระวัง (ลบบอร์ดเดียวล้างคอลัมน์ การ์ด และ label ทั้งหมดที่อยู่ข้างใน), และการตรวจ foreign key เพิ่มต้นทุนเล็กน้อยตอนเขียนข้อมูล ข้อแลกเปลี่ยนเหล่านั้นคือสิ่งที่เราต้องการพอดี: บอร์ดที่ถูกลบ ควร พาสิ่งที่อยู่ในนั้นไปด้วย
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”การ “สร้าง” schema ในขั้นนี้หมายถึงการออกแบบให้แม่นยำ — ปักหมุดจุดประสงค์ของทุกตารางและทุกคอลัมน์ บทเรียนถัดไปจะเปลี่ยนดีไซน์นี้เป็นไฟล์ migration จริง นี่คือแต่ละตาราง อธิบายทีละคอลัมน์
ตารางบัญชีผู้ใช้ หนึ่งแถวต่อหนึ่งคนที่ล็อกอินได้
id uuid— primary key ค่าเริ่มต้นเป็นgen_random_uuid()email text— ตัวระบุสำหรับล็อกอิน;uniqueและnot nullเพื่อไม่ให้สองบัญชีใช้อีเมลเดียวกันpassword_hash text— ค่าแฮช Argon2/bcrypt ของรหัสผ่าน ไม่เคยเก็บ plaintext โดยโมดูล Authentication จะเป็นคนกำหนดdisplay_name text— ชื่อที่อ่านได้ ที่แสดงบนการ์ดและรายชื่อสมาชิกcreated_at timestamptz— เวลาที่สร้างบัญชี ค่าเริ่มต้นเป็นnow()
บอร์ด Kanban จุดสูงสุดของลำดับชั้นเนื้อหา
id uuid— primary keyowner_id uuid— foreign key ไปยังusers(id)พร้อมon delete cascade; ผู้ใช้ที่สร้างบอร์ด ลบผู้ใช้แล้วบอร์ดที่เขาเป็นเจ้าของก็หายไปด้วยtitle text— ชื่อบอร์ดcreated_at timestamptz— เวลาสร้าง ค่าเริ่มต้นเป็นnow()
board_members
หัวข้อที่มีชื่อว่า “board_members”ตาราง join แบบ many-to-many ระหว่าง users กับ boards — ใครได้รับอนุญาตให้ดูและแก้ไขบอร์ด นี่คือสิ่งที่โมดูล Authentication และ REST ตรวจในทุก request
board_id uuid— foreign key ไปยังboards(id),on delete cascadeuser_id uuid— foreign key ไปยังusers(id),on delete cascaderole text— บทบาทของสมาชิกบนบอร์ดนี้ ('member'โดยค่าเริ่มต้น; คุณอาจเพิ่ม'admin'ในภายหลัง),not null- Primary key เป็นแบบ composite
(board_id, user_id)ซึ่งรับประกันว่าผู้ใช้หนึ่งคนปรากฏได้มากสุดหนึ่งครั้งต่อบอร์ด
columns
หัวข้อที่มีชื่อว่า “columns”เลนแนวตั้งบนบอร์ด — “To Do”, “In Progress”, “Done”
id uuid— primary keyboard_id uuid— foreign key ไปยังboards(id),on delete cascadetitle text— หัวข้อคอลัมน์position double precision— คีย์การจัดเรียงที่ตัดสินลำดับคอลัมน์จากซ้ายไปขวา กลยุทธ์การจัดเรียงแบบเศษส่วนเป็นหัวข้อทั้งหมดของบทเรียน indexes & ordering
การ์ดงานที่อยู่ภายในคอลัมน์ หน่วยที่ผู้ใช้ลากไปมา
id uuid— primary keycolumn_id uuid— foreign key ไปยังcolumns(id),on delete cascadetitle text— ชื่อการ์ดdescription text— เนื้อหายาวเสริม (nullable — การ์ดมีอยู่ได้ด้วยแค่ชื่อ)position double precision— คีย์การจัดเรียงสำหรับลำดับบนลงล่างภายในคอลัมน์created_at timestamptz— เวลาสร้าง ค่าเริ่มต้นเป็นnow()
แท็กสีที่นิยามบนบอร์ดและใช้ซ้ำได้ข้ามการ์ดของบอร์ดนั้น
id uuid— primary keyboard_id uuid— foreign key ไปยังboards(id),on delete cascadelabel สังกัดบอร์ด ไม่ใช่ระดับ globalname text— ข้อความของ label (“Bug”, “Urgent”)color text— ค่าสี โดยทั่วไปเป็น hex string เช่น#e11d48
card_labels
หัวข้อที่มีชื่อว่า “card_labels”ตาราง join แบบ many-to-many ระหว่าง cards กับ labels — label ไหนถูกติดบนการ์ดไหน
card_id uuid— foreign key ไปยังcards(id),on delete cascadelabel_id uuid— foreign key ไปยังlabels(id),on delete cascade- Primary key เป็นแบบ composite
(card_id, label_id)เพื่อไม่ให้ label เดียวกันถูกติดบนการ์ดเดียวกันสองครั้ง
สายโซ่ cascade
หัวข้อที่มีชื่อว่า “สายโซ่ cascade”สังเกตว่า foreign key ประกอบกันเป็นสายโซ่: users → boards → columns → cards บวกกิ่งข้าง boards → labels และตาราง join สองตาราง เพราะ foreign key ทุกเส้นใช้ on delete cascade การลบบอร์ดเดียวจึงกวาดล้างอย่างสะอาด:
columnsของบอร์ดนั้นโดนลบ ซึ่งลบcardsข้างในต่อ แล้วลบแถวcard_labelsที่ตรงกันต่ออีกทอดlabelsของบอร์ดโดนลบ ซึ่งเคลียร์แถวcard_labelsที่เกี่ยวข้องไปด้วย- แถว
board_membersของบอร์ดโดนลบ
DELETE FROM boards WHERE id = ... ครั้งเดียวไม่ทิ้งแถวกำพร้าไว้ที่ใดเลย นั่นคือผลตอบแทนของการบังคับความสัมพันธ์ในฐานข้อมูลแทนที่จะหวังว่าแอปพลิเคชันจะจำล้างข้อมูลเอง
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ยังไม่มีโค้ดให้รันตอนนี้ — นี่คือบทเรียนการออกแบบ — แต่คุณตรวจสอบความสมเหตุสมผลของโมเดลกับคำถามเหล่านี้ได้ก่อนไปต่อ:
- การ์ดชี้ไปยังคอลัมน์ที่ไม่มีอยู่ได้ไหม? ไม่ได้ — foreign key
column_idห้ามไว้ - ผู้ใช้คนเดียวถูกเพิ่มเข้าบอร์ดเดียวสองครั้งได้ไหม? ไม่ได้ — composite primary key
(board_id, user_id)กันไว้ - ถ้าผู้ใช้ลบบัญชีตัวเอง บอร์ดที่เขาเป็นเจ้าของจะเป็นอย่างไร? บอร์ดจะ cascade-delete พาคอลัมน์ การ์ด label และการเป็นสมาชิกไปด้วย
- ตำแหน่งของการ์ดในคอลัมน์มาจากไหน? จากคอลัมน์
position double precisionซึ่งอธิบายในบทเรียนเรื่องการจัดเรียง
ถ้าคำตอบทั้งสี่ตรงกับไดอะแกรมด้านบน แสดงว่าดีไซน์แน่นหนาและพร้อมกลายเป็น migration
คุณออกแบบ data model ของ TaskFlow ครบทั้งหมด: เจ็ดตาราง — users, boards, board_members, columns, cards, labels, card_labels — พร้อมลำดับชั้นความเป็นเจ้าของที่ชัดเจนและตาราง join แบบ many-to-many สองตาราง คุณได้เห็นว่าทำไม UUID primary key จึงเหนือกว่าเลขจำนวนเต็มเพิ่มอัตโนมัติสำหรับแอปเรียลไทม์ที่ขับเคลื่อนด้วย client, foreign key บังคับ referential integrity อย่างไร และ ON DELETE CASCADE ทำให้การลบบอร์ดเดียวลบทุกอย่างข้างใต้ได้อย่างสะอาดอย่างไร ต่อไป เราจะเปลี่ยนดีไซน์นี้เป็น migration จริงที่มีเวอร์ชันด้วย sqlx-cli