การสร้าง table
ทุก table เริ่มต้นชีวิตด้วยคำสั่ง CREATE TABLE คุณตั้งชื่อ table จากนั้นไล่รายชื่อ column ไว้ในวงเล็บ โดยแต่ละตัวมีชื่อและ data type ตัว server จะเก็บคำนิยามนั้นไว้ และนับจากนั้นก็จะบังคับใช้กับทุก row ที่คุณ insert การสร้าง table คือ DDL — data definition language — และมักจะเขียนและรันเป็น SQL ธรรมดา ไม่ว่าคุณจะพิมพ์ลงใน psql หรือป้อนให้กับเครื่องมือ migration
เราจะสร้าง table products สำหรับร้านค้าเล็ก ๆ แล้วค่อย ๆ ทำให้ table นี้เติบโตขึ้นตามบทเรียน
รูปทรงพื้นฐาน
หัวข้อที่มีชื่อว่า “รูปทรงพื้นฐาน”อย่างน้อยที่สุดคือชื่อ table และ column หนึ่งตัวขึ้นไป แต่ละ column คือชื่อตามด้วยชนิด โดย column ต่าง ๆ คั่นด้วยจุลภาค
CREATE TABLE products ( id integer, name text, price numeric(10, 2));นั่นสร้าง table ที่มีสาม column: id ที่เป็นจำนวนเต็ม, name ที่เป็น text และ price ที่เก็บเป็นทศนิยมแบบแม่นยำได้ถึงสิบหลักและสองหลักหลังจุด แบบนี้ใช้ได้ แต่หลวมเกินไป — ไม่มีอะไรห้ามสินค้าสองชิ้นใช้ id ร่วมกัน และทุก column ปล่อยว่างไว้ได้หมด เราจะแก้ปัญหานั้นด้วย primary key และ constraints ในลำดับถัดไป
ใน pgAdmin: หลังจากรันคำสั่งใน Query Tool ให้ refresh โหนด Tables ในแผนผัง browser แล้ว table products ใหม่จะปรากฏขึ้น พอกางออกจะเห็นโหนด Columns ที่ไล่รายชื่อแต่ละ column พร้อมชนิดข้อมูล
ตรวจสอบ table ด้วย backslash-d
หัวข้อที่มีชื่อว่า “ตรวจสอบ table ด้วย backslash-d”ใน psql คำสั่ง \d จะอธิบาย table ให้ครบ ทั้ง column, ชนิดข้อมูล, ความเป็น null ได้ และ default ต่าง ๆ นี่คือวิธีที่เร็วที่สุดในการยืนยันว่า table มีหน้าตาอย่างที่คุณตั้งใจไว้
\d products Table "public.products" Column | Type | Collation | Nullable | Default--------+---------------+-----------+----------+--------- id | integer | | | name | text | | | price | numeric(10,2) | | |ตอนนี้ทุก column สามารถเป็น null ได้และยังไม่มี default เมื่อเราเพิ่ม primary key และ constraints เซลล์เหล่านั้นจะค่อย ๆ เต็มขึ้น
สร้าง primary key โดยอัตโนมัติ
หัวข้อที่มีชื่อว่า “สร้าง primary key โดยอัตโนมัติ”คุณแทบไม่อยากกำหนดเลข id ด้วยมือเลย PostgreSQL ทำให้คุณได้ วิธีสมัยใหม่คือ GENERATED ALWAYS AS IDENTITY ซึ่งให้ server จัดหาค่าถัดไปให้อัตโนมัติ ทางลัดแบบเก่าคือ SERIAL ซึ่งคุณยังคงพบเห็นได้ในโค้ดที่มีอยู่จำนวนมาก
CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);ตอนนี้ server เป็นคนเติม id ให้ พร้อมประกาศเป็น primary key ส่วน name กับ price ก็ปล่อยว่างไม่ได้อีกต่อไป table เดียวกันนี้ถ้าเขียนด้วยสไตล์เก่าจะใช้ id bigserial PRIMARY KEY ทั้งสองแบบให้คีย์ที่เพิ่มค่าอัตโนมัติ แบบ IDENTITY เป็นแบบที่เป็นมาตรฐาน SQL และเป็นที่นิยมกว่าใน PostgreSQL สมัยใหม่
สร้าง table จาก driver
หัวข้อที่มีชื่อว่า “สร้าง table จาก driver”แอปพลิเคชันส่วนใหญ่รันคำสั่ง CREATE TABLE เพียงครั้งเดียว ตอน setup หรือผ่านขั้นตอน migration แทนที่จะรันในทุก request นี่คือ migration เดียวกันที่ส่งจากแต่ละ driver — มีประโยชน์เมื่อแอปพลิเคชันของคุณเป็นเจ้าของ schema เอง
CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);await pool.query(` CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )`);cur.execute(""" CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )""")_, err := conn.Exec(ctx, ` CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )`)sqlx::query( "CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )",).execute(&pool).await?;SQL เหมือนกันทุกที่ ต่างกันแค่วิธีที่คุณส่งไปให้ server เท่านั้น เนื่องจาก DDL ไม่รับ input จากผู้ใช้ ที่นี่จึงไม่มี parameter ให้ bind
IF NOT EXISTS และ DROP TABLE
หัวข้อที่มีชื่อว่า “IF NOT EXISTS และ DROP TABLE”การรัน CREATE TABLE products เป็นครั้งที่สองจะเป็น error เพราะ table มีอยู่แล้ว การเพิ่ม IF NOT EXISTS ทำให้คำสั่งกลายเป็น no-op เมื่อ table มีอยู่แล้ว ซึ่งสะดวกในสคริปต์ setup ที่อาจถูกรันมากกว่าหนึ่งครั้ง
CREATE TABLE IF NOT EXISTS products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);ถ้าต้องการลบ table พร้อม row ทั้งหมด ให้ใช้ DROP TABLE คำสั่งนี้มีผลทันทีและถาวร จึงต้องระวังพอ ๆ กับ DELETE แบบ IF EXISTS ช่วยเลี่ยง error เมื่อ table หายไปแล้ว
DROP TABLE IF EXISTS products;ใน pgAdmin: คุณยัง drop table ได้โดยคลิกขวาที่ table ในแผนผัง browser แล้วเลือก Delete/Drop แต่การรัน DROP TABLE ใน Query Tool จะเก็บการกระทำนั้นไว้ในประวัติสคริปต์ของคุณ
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- เลือก
GENERATED ALWAYS AS IDENTITYมากกว่าSERIALสำหรับ table ใหม่ เพราะเป็น syntax มาตรฐาน SQL เลี่ยงความแปลกประหลาดเรื่องความเป็นเจ้าของของ sequence ที่SERIALสร้างขึ้นเบื้องหลัง และอ่านได้ชัดเจนกว่า - ใช้
bigintแทนที่จะเป็นintegerสำหรับ identity key บน table ที่คุณคาดว่าจะเติบโตintegerขนาดสี่ไบต์จะหมดที่ราว ๆ สองพันล้าน row และการสลับชนิดในภายหลังนั้นสร้างความปั่นป่วน - เพิ่ม
IF NOT EXISTSลงในสคริปต์สร้างที่อาจถูกรันซ้ำ ๆ แต่อย่าใส่ในการ migration แบบครั้งเดียวที่ table ซ้ำควรเป็น error ที่คุณสังเกตเห็น DROP TABLEลบทั้งโครงสร้างและทุก row โดยไม่มีการยืนยัน ตรวจสอบชื่อ table ให้ดี และควร drop ภายใน transaction ที่คุณ roll back ได้หากไม่แน่ใจ- ชื่อ column และ table จะถูกแปลงเป็นตัวพิมพ์เล็กเว้นแต่จะใส่ double-quote ดังนั้นจงเลือกชื่อตัวพิมพ์เล็กแบบ snake_case เพื่อหลีกเลี่ยงความประหลาดใจ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| ออกแบบ schema ให้ครบตั้งแต่ต้น | โครงสร้างชัดเจน มี migration discipline ตั้งแต่วันแรก | ใช้เวลาเก็บ requirement นานขึ้นก่อนเริ่มเขียนโค้ด |
| เพิ่ม column ทีหลังตามที่เจอ requirement จริง | ยืดหยุ่น ปรับตามธุรกิจได้เร็ว | schema drift ต้องตามแก้ constraint และ migration ย้อนหลัง |
bigint GENERATED ALWAYS AS IDENTITY เป็น primary key | เรียงลำดับได้ตามเวลา insert ขนาดเล็ก index เร็ว | เดา id ถัดไปได้ เปิดเผยจำนวน row |
uuid เป็น primary key | ไม่ซ้ำกันได้ในระดับ global สร้างจาก client ได้โดยไม่ต้อง round trip ไปที่ server | ขนาดใหญ่กว่า เรียงลำดับตามเวลาไม่ได้ตามธรรมชาติ ทำให้ index กระจายตัว |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- สร้าง table โดยไม่มี primary key — ทุก table ควรมี primary key เสมอ ไม่งั้นจะ update/delete row เจาะจงได้ยากและ replication บางแบบก็ต้องมี primary key
- ใช้
varchar(255)กับทุก column แบบไม่คิด — เลือกความยาวที่มีความหมายจริงตามกฎธุรกิจ หรือใช้textไปเลยถ้าไม่มีขีดจำกัดที่แท้จริง เพราะvarchar(255)ที่ copy กันมาไม่ได้ป้องกันอะไรและไม่ได้เร็วกว่าtext - ลืมใส่
NOT NULLให้ column ที่ต้องมีค่าเสมอ — ถ้า column อย่างnameหรือpriceไม่ควรว่างเปล่าได้ ต้องประกาศNOT NULLตั้งแต่ตอนสร้าง table ไม่ใช่ปล่อยให้แอปพลิเคชันเป็นคนคอยเช็คเอง
💡 ตัวอย่างจากของจริง
Stripe — ใช้ id แบบ string ที่มี prefix บอกประเภท object เช่น
cus_...สำหรับ customer หรือch_...สำหรับ charge แทนที่จะใช้ UUID ดิบ ๆ ทำให้ id ทั้ง unique และอ่านแล้วรู้ทันทีว่าเป็น object ประเภทไหน ช่วยเวลา debug logNotion — เก็บเนื้อหา block ที่มีโครงสร้างยืดหยุ่นสูงไว้ใน column แบบ JSONB ควบคู่กับ metadata อย่าง ownership และ permission ที่ยังคงเก็บแบบ relational ปกติเพื่อให้ query และบังคับ constraint ได้ตรงไปตรงมา