ข้ามไปยังเนื้อหา

การสร้าง 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 พร้อมชนิดข้อมูล

ใน 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 เซลล์เหล่านั้นจะค่อย ๆ เต็มขึ้น

คุณแทบไม่อยากกำหนดเลข 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 สมัยใหม่

แอปพลิเคชันส่วนใหญ่รันคำสั่ง 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
);

SQL เหมือนกันทุกที่ ต่างกันแค่วิธีที่คุณส่งไปให้ server เท่านั้น เนื่องจาก DDL ไม่รับ input จากผู้ใช้ ที่นี่จึงไม่มี parameter ให้ bind

การรัน 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 เพื่อหลีกเลี่ยงความประหลาดใจ
ตัวเลือกBenefitCost
ออกแบบ 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 log

Notion — เก็บเนื้อหา block ที่มีโครงสร้างยืดหยุ่นสูงไว้ใน column แบบ JSONB ควบคู่กับ metadata อย่าง ownership และ permission ที่ยังคงเก็บแบบ relational ปกติเพื่อให้ query และบังคับ constraint ได้ตรงไปตรงมา

clause ใดที่ทำให้ PostgreSQL จัดหาค่า primary-key โดยอัตโนมัติด้วย syntax มาตรฐาน SQL?
การเพิ่ม IF NOT EXISTS ลงในคำสั่ง CREATE TABLE ทำอะไร?
DROP TABLE products; ทำอะไร?