Skip to content

บทที่ 1 — SQL พื้นฐาน (DDL + DML)

← บทที่ 0 | สารบัญ | บทที่ 2 →

หลังจบบท คุณจะ:

  • สร้าง/ลบ table ได้
  • INSERT, UPDATE, DELETE, SELECT ได้คล่อง
  • ใช้ WHERE, ORDER BY, LIMIT, DISTINCT
  • เข้าใจ data type ของ PostgreSQL
  • ตั้ง constraint (NOT NULL, UNIQUE, CHECK, DEFAULT)

📖 Glossary คำอ่าน — ศัพท์ DDL ในบทนี้

คำคำอ่านสั้น ๆ
SQLเอส-คิว-แอล / ซีเควลภาษา query
DDLดี-ดี-แอลData Definition Language
DMLดี-เอ็ม-แอลData Manipulation Language
SERIALซี-เรียลauto-increment integer (legacy)
IDENTITYไอ-เดน-ทิ-ทีauto-increment แบบใหม่ตาม SQL standard
VARCHARวาร์-ชาร์variable-length string
NUMERICนู-เม-ริกตัวเลขทศนิยมแม่นยำ
BOOLEANบูล-เลียนTRUE/FALSE
TEXTเท็กซ์ทstring ไม่จำกัด
JSONBเจ-สัน-บีJSON binary
TIMESTAMPTZไทม์-สแตมป์-ที-ซีtimestamp + timezone
UUIDยู-ยู-ไอ-ดีunique 128-bit identifier
UPSERTอัพ-เซิร์ทinsert + update if exists
constraintคอน-สเตรนท์กฎที่ DB บังคับ
idempotentไอ-เด็ม-โพ-เทนต์ทำซ้ำได้ผลเหมือนเดิม

1. DDL vs DML

DDL (Data Definition Language)DML (Data Manipulation Language)
ทำอะไรกำหนด schemaจัดการข้อมูล
คำสั่งCREATE, ALTER, DROP, TRUNCATESELECT, INSERT, UPDATE, DELETE

2. CREATE TABLE — สร้างตาราง

เริ่มจากตารางง่ายสุดก่อน — แค่ id, name, age 3 column:

sql
-- minimal CREATE TABLE — เริ่มจากนี้ก่อน
CREATE TABLE products_minimal (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    age INTEGER
);

อธิบาย:

  • id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY — เลขที่ DB นับเองอัตโนมัติ (1, 2, 3, ...) เป็น primary key (ระบุตัวตน 1 row)
  • VARCHAR(200) NOT NULL — string ยาวไม่เกิน 200 ตัวอักษร, ห้ามว่าง
  • INTEGER — ตัวเลขจำนวนเต็ม

ตอนนี้ลองเพิ่ม column ทีละชนิด สำหรับตาราง products จริง:

sql
CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
    stock INTEGER NOT NULL DEFAULT 0,
    category VARCHAR(50),
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    description TEXT,
    metadata JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

อธิบายทุกบรรทัด:

  • id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY — auto-increment integer + primary key (SQL standard ตั้งแต่ PG 10 — แนะนำใช้แทน SERIAL ที่เป็น legacy)
  • VARCHAR(200) NOT NULL — string max 200 chars, ห้าม null
  • NUMERIC(10, 2) — ตัวเลขแม่นยำ 10 หลัก ทศนิยม 2 ตำแหน่ง (เหมาะกับเงิน/ราคา — ไม่ปัดเศษ)
  • CHECK (price >= 0) — บังคับว่า price ต้อง ≥ 0 (ถ้าใส่ค่าติดลบ DB จะ reject)
  • DEFAULT 0 — ถ้าไม่ใส่ค่า → ใช้ค่า default
  • BOOLEAN — true/false (จริง/เท็จ)
  • TEXT — string ยาวไม่จำกัด (เหมาะกับ description)
  • JSONBJSON ที่เก็บเป็น binary format (ภายในเก็บเป็น byte ไม่ใช่ text) ทำให้ index ได้, query เร็ว (Postgres feature)
  • TIMESTAMPTZ — timestamp with timezone (เก็บเวลาเป็น UTC + จำ timezone — ไม่เพี้ยนข้าม region)

💡 SERIAL vs GENERATED ALWAYS AS IDENTITY — ก่อน PG 10 ใช้ SERIAL (legacy: behind-the-scenes ใช้ sequence + permission ซับซ้อน) ตั้งแต่ PG 10 มี GENERATED ALWAYS AS IDENTITY เป็น SQL standard แนะนำใช้แบบใหม่สำหรับตาราง ใหม่ — SERIAL ยังพบเห็นในโค้ดเก่าเยอะ


3. Data Types — PostgreSQL

Numeric

sql
SMALLINT          -- -32K ถึง 32K (2 bytes)
INTEGER / INT     -- -2.1B ถึง 2.1B (4 bytes)
BIGINT            -- ใหญ่มาก (8 bytes)

-- Auto-increment (เลขที่ DB นับเองทีละ 1)
BIGINT GENERATED ALWAYS AS IDENTITY  -- ⭐ แนะนำ (SQL standard ตั้งแต่ PG 10)
SERIAL                                -- legacy — INT + auto-increment
BIGSERIAL                             -- legacy — BIGINT + auto-increment

NUMERIC(10, 2)    -- ตัวเลขแม่นยำ (money)
DECIMAL(10, 2)    -- alias ของ NUMERIC
REAL              -- float (4 bytes, ไม่แม่นยำ)
DOUBLE PRECISION  -- float (8 bytes, ไม่แม่นยำ)

⚠️ อย่าใช้ FLOAT/REAL กับเงิน — FLOAT เก็บเป็น binary ทำให้ทศนิยมบางตัวเก็บไม่ตรงเป๊ะ เช่น 0.1 + 0.2 = 0.30000000000000004 (ไม่ใช่ 0.3 พอดี) → ใช้กับเงินผิดทันที → ใช้ NUMERIC แทน (เก็บแบบ decimal แม่นยำ)

String

sql
VARCHAR(n)        -- string ≤ n chars
CHAR(n)           -- string เสมอ n chars (pad space)
TEXT              -- ไม่จำกัด — ใช้ในทั่วไป (เร็วเท่ากัน)

ใน PostgreSQL — TEXT กับ VARCHAR(n) ความเร็วเท่ากัน — ใช้ TEXT + CHECK constraint ก็ได้

💡 มี CITEXT (case-insensitive text — extension citext) สำหรับ column ที่ต้องเทียบโดยไม่สนตัวพิมพ์เล็ก/ใหญ่ เช่น email/username — เก็บแบบเดิมแต่ compare แบบ lowercase

Date/Time

sql
DATE              -- 2026-05-18
TIME              -- 14:30:25
TIMESTAMP         -- 2026-05-18 14:30:25 (ไม่มี timezone — เสี่ยง bug)
TIMESTAMPTZ       -- 2026-05-18 14:30:25+07 (มี timezone — ใช้ตัวนี้!)
INTERVAL          -- ช่วงเวลา '2 hours 30 minutes'

Boolean

sql
BOOLEAN           -- TRUE / FALSE / NULL

JSON

sql
JSON              -- เก็บ raw text — ingest เร็ว, query ช้า (ไม่ index ได้, ต้อง parse ทุกครั้ง) + เก็บ key order/whitespace/key ซ้ำตามที่ใส่
JSONB             -- binary format — query เร็ว / index ได้, ingest ช้ากว่าเล็กน้อย (ต้อง parse + เรียบเรียงตอน insert) ⭐

↪️ JSONB ทำได้มากกว่านี้เยอะ (query เข้าไปใน object, index, full-text) — เจาะลึกใน บทที่ 7

UUID

UUID (Universally Unique Identifier) = ตัวเลขสุ่ม unique 128-bit ที่เกือบเป็นไปไม่ได้ที่จะชนกัน — ใช้แทน id แบบเลขเรียง (1, 2, 3) เมื่อ:

  • ต้องรวม data ข้ามระบบ (id เรียงคนละชุดจะชนกัน)
  • ไม่อยาก expose จำนวน record ออกไป (เห็น id = 1234 รู้เลยว่ามี user ~1234 คน)
sql
UUID              -- 550e8400-e29b-41d4-a716-446655440000 (รูปแบบ string 36 ตัวอักษร)

-- สร้าง UUID (PG 13+) — ใช้ฟังก์ชัน built-in ไม่ต้องลง extension
SELECT gen_random_uuid();

💡 gen_random_uuid() vs uuid_generate_v4() — รุ่นเก่าใช้ CREATE EXTENSION "uuid-ossp"; SELECT uuid_generate_v4(); ตั้งแต่ PG 13 มี gen_random_uuid() built-in ไม่ต้องลง extension แล้ว — ใช้ตัวนี้เป็นหลัก

💡 UUIDv7 (RFC 9562, May 2024)UUID รุ่นใหม่ที่ฝัง timestamp ไว้ในตัว ทำให้ "เรียงตามเวลา" ได้ → index ใน B-tree เร็วกว่า UUIDv4 (ที่สุ่มล้วน) — สำหรับระบบใหม่ที่สร้าง UUID เยอะ พิจารณา UUIDv7 (PG ยังไม่มี built-in function ต้องใช้ extension เช่น pg_uuidv7 หรือสร้างจากฝั่ง app)

Array

sql
INTEGER[]         -- array ของ integer
TEXT[]            -- array ของ text

-- ตัวอย่างต้องมี table ก่อน — สร้าง tags ที่มี column ชื่อ tag_values (เก็บ array ของ integer)
CREATE TABLE tags (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tag_values INTEGER[]              -- column ชื่อ tag_values เก็บ array
);

-- ใช้
INSERT INTO tags (tag_values) VALUES ('{1, 2, 3}');     -- '{1,2,3}' = array literal ของ Postgres
SELECT * FROM tags WHERE 2 = ANY(tag_values);            -- ANY(tag_values) = "มีค่า 2 อยู่ใน array ไหม"

💡 PostgreSQL array literal = string ที่ขึ้นต้นด้วย { และปิดด้วย } คั่นด้วย comma — เช่น '{1,2,3}' = array 3 element, '{"a","b"}' = text array 2 element

⚠️ อย่าตั้งชื่อ column ทับ reserved word — เช่น values, user, order, select เป็นต้น Postgres อาจอนุญาตในบาง position แต่ fragile + งง — INSERT INTO tags (values) VALUES (...) จริง ๆ parse ไม่ผ่านเพราะ values ติด keyword VALUES (ถ้าจำเป็นต้องใช้ ต้อง quote: "values") ตั้งชื่ออื่นดีกว่า เช่น tag_values

Enum

sql
CREATE TYPE order_status AS ENUM ('PENDING', 'PAID', 'SHIPPED', 'CANCELLED');

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'PENDING'
);

4. Constraints — บังคับ rule

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    age INTEGER CHECK (age >= 13 AND age <= 120),
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    parent_id INTEGER REFERENCES users(id),
    
    CONSTRAINT email_lowercase CHECK (email = LOWER(email))
);
Constraintทำอะไร
NOT NULLห้าม null
UNIQUEห้ามซ้ำ
PRIMARY KEYunique + not null + เป็น index
DEFAULT xค่าถ้าไม่ใส่
CHECK (...)condition ที่ต้อง true
REFERENCES other(col)foreign key

Named Constraint

sql
ALTER TABLE users 
ADD CONSTRAINT users_email_lowercase CHECK (email = LOWER(email));

ALTER TABLE users DROP CONSTRAINT users_email_lowercase;

5. ALTER TABLE — เปลี่ยน schema

sql
-- เพิ่ม column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- เปลี่ยน type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;

-- เพิ่ม default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'ACTIVE';

-- ลบ default
ALTER TABLE users ALTER COLUMN status DROP DEFAULT;

-- Rename column
ALTER TABLE users RENAME COLUMN phone TO phone_number;

-- Rename table
ALTER TABLE users RENAME TO accounts;

-- ลบ column
ALTER TABLE users DROP COLUMN phone_number;

-- เพิ่ม constraint
ALTER TABLE users ADD CONSTRAINT age_positive CHECK (age > 0);

⚠️ Production: เปลี่ยน schema ระวัง — table ใหญ่ ALTER อาจ lock นาน → ใช้ migration tool (Flyway, Liquibase)


6. DROP — ลบ

DROP ลบทั้ง table (schema + data) ส่วน TRUNCATE ลบแค่ data เหลือ schema — ⚠️ ทั้งคู่ไม่มี undo ดังนั้นใน production ต้องระวังมาก (ใช้ IF EXISTS กัน error และเข้าใจผลของ CASCADE ก่อนรัน):

sql
DROP TABLE users;                       -- ลบ table
DROP TABLE IF EXISTS users;             -- ไม่ error ถ้าไม่มี
DROP TABLE users CASCADE;               -- ลบ + ลบ FK ที่อ้างถึง
TRUNCATE TABLE users;                   -- ลบ data ทั้งหมด (เก็บ schema)
TRUNCATE TABLE users RESTART IDENTITY;  -- reset SERIAL counter ด้วย

⚠️ DROP/TRUNCATE ไม่มี undo — ใช้ใน production ระวัง


7. INSERT — เพิ่มข้อมูล

INSERT เพิ่มข้อมูลลง table — ควรระบุ column เสมอ (ไม่พังเมื่อเพิ่ม column ใหม่), insert หลาย row ในคำสั่งเดียวเพื่อความเร็ว, RETURNING เพื่อดูค่าที่ DB สร้าง (เช่น id) และ ON CONFLICT (upsert) สำหรับ "เพิ่มหรืออัปเดตถ้ามีอยู่แล้ว":

sql
-- แบบ 1: insert ทุก column ตามลำดับ (ไม่แนะนำ — เปราะ + เสี่ยงชน sequence)
INSERT INTO users VALUES (1, 'a@b.com', 'Anna', 25, ...);
-- ⚠️ ระบุ id = 1 เองทับ SERIAL/IDENTITY — sequence ไม่รู้เรื่อง ครั้งต่อไป insert จะพยายามให้ id = 1 อีก
--    เกิด unique violation → ปล่อยให้ DB gen เอง (ละ id ทิ้งจาก column list) ดีกว่า

-- แบบ 2: ระบุ column (แนะนำ — ไม่พังถ้าเพิ่ม column ใหม่ + ปล่อย id ให้ DB gen)
INSERT INTO users (email, name, age) VALUES ('a@b.com', 'Anna', 25);

-- หลาย row พร้อมกัน
INSERT INTO users (email, name, age) VALUES
    ('a@b.com', 'Anna', 25),
    ('b@c.com', 'Ben', 30),
    ('c@d.com', 'Carol', 28);

-- Insert + RETURNING (ดู id ที่สร้าง)
INSERT INTO users (email, name, age) VALUES ('d@e.com', 'Dan', 22)
RETURNING id, created_at;

-- Insert จากผล SELECT (คัดลอกข้อมูลจาก query มาใส่)
INSERT INTO users_archive (email, name)
SELECT email, name FROM users WHERE created_at < '2024-01-01';

-- Upsert (อัพ-เซิร์ท = insert + update ถ้ามีอยู่แล้วก็ update แทน)
INSERT INTO users (email, name) VALUES ('a@b.com', 'Anna Updated')
ON CONFLICT (email)                   -- "ถ้าชน unique constraint ที่ email"
DO UPDATE SET name = EXCLUDED.name;   -- EXCLUDED = "แถวที่พยายาม insert เข้ามา (ที่ชน)"

-- ถ้ามี → ไม่ทำอะไร
INSERT INTO users (email, name) VALUES ('a@b.com', 'Anna')
ON CONFLICT (email) DO NOTHING;

⚠️ ON CONFLICT (col) ต้องมี UNIQUE/PRIMARY KEY constraint บน col — ถ้าตารางไม่มี unique constraint บน column ที่ระบุ จะ error there is no unique or exclusion constraint matching the ON CONFLICT specification ตัวอย่าง users.email ในบทนี้มี UNIQUE แล้ว → ใช้ได้


8. SELECT — ดึงข้อมูล

SELECT คือคำสั่งที่ใช้บ่อยที่สุด — เลือก column, ตั้ง alias, คำนวณ และที่สำคัญคือ WHERE สำหรับกรองแถว เริ่มจากพื้นฐานนี้ให้แม่นก่อน เพราะทุกอย่างต่อจากนี้ (JOIN, aggregate) ล้วนต่อยอดจาก SELECT:

Basic

sql
-- ทุก column ทุก row
SELECT * FROM users;

-- เลือก column
SELECT id, email, name FROM users;

-- เปลี่ยนชื่อ (alias)
SELECT id, name AS full_name FROM users;

-- คำนวณ
SELECT name, age, age + 10 AS age_in_10_years FROM users;
SELECT name, EXTRACT(YEAR FROM created_at) AS join_year FROM users;

WHERE — filter

sql
SELECT * FROM users WHERE age > 18;
SELECT * FROM users WHERE email = 'a@b.com';
SELECT * FROM users WHERE name LIKE 'A%';        -- ขึ้นต้นด้วย A
SELECT * FROM users WHERE name ILIKE '%anna%';    -- ไม่สนตัวพิมพ์เล็ก/ใหญ่ (case-insensitive)
SELECT * FROM users WHERE age BETWEEN 20 AND 30;
SELECT * FROM users WHERE age IN (20, 25, 30);
SELECT * FROM users WHERE name IS NULL;
SELECT * FROM users WHERE name IS NOT NULL;
SELECT * FROM users WHERE age > 18 AND status = 'ACTIVE';
SELECT * FROM users WHERE age < 18 OR age > 65;
SELECT * FROM users WHERE NOT (status = 'INACTIVE');

Operators

sql
=  !=  <>  <  >  <=  >=
LIKE / ILIKE   -- จับคู่ pattern (รูปแบบข้อความ)
    %          -- แทนตัวอักษร 0 ตัวขึ้นไป
    _          -- แทนตัวอักษร 1 ตัวพอดี
IN (...)       -- อยู่ใน list ที่กำหนด
BETWEEN a AND b
IS NULL / IS NOT NULL
AND / OR / NOT

⚠️ NULL ใช้ IS NULL ไม่ใช่ =

sql
WHERE name = NULL       -- ❌ ผิดเสมอ (return NULL/unknown → WHERE ถือว่า "ไม่ match" → ผลลัพธ์ว่าง)
WHERE name IS NULL      -- ✅

ทำไม? — NULL = "ไม่รู้ค่า" → unknown = unknown ผลก็ unknown (ไม่ใช่ true ไม่ใช่ false) — เปรียบเทียบไทย: "ไม่รู้ = ไม่รู้ → เรารู้ไหมว่าเท่ากัน? ก็ไม่รู้" WHERE จะ keep เฉพาะ row ที่เป็น TRUE เท่านั้น — unknown ถือว่า "ไม่ match" → ผลลัพธ์ว่าง


9. ORDER BY + LIMIT + OFFSET

sql
-- เรียง
SELECT * FROM users ORDER BY age;              -- น้อย → มาก (default)
SELECT * FROM users ORDER BY age DESC;          -- มาก → น้อย
SELECT * FROM users ORDER BY age DESC, name;    -- multi-column

-- Top N
SELECT * FROM users ORDER BY created_at DESC LIMIT 10;

-- Pagination (page 1-indexed, size 10 — page 3 = ข้าม 2 page แรก = OFFSET 20)
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;  -- page 3 (1-indexed, size 10)

⚠️ OFFSET ใหญ่ ๆ ช้าOFFSET 1000000 ต้อง scan + ทิ้ง 1M row → ใช้ keyset pagination แทน (บทที่ 4)


10. DISTINCT — ลบซ้ำ

DISTINCT กรองแถวที่ซ้ำกันออก เหลือเฉพาะค่าที่ไม่ซ้ำ — ใช้กับ column เดียว (ค่า unique) หรือหลาย column (combination ที่ไม่ซ้ำ) มีประโยชน์ตอนอยากรู้ว่ามีค่าอะไรบ้างในคอลัมน์:

sql
-- unique values
SELECT DISTINCT category FROM products;

-- unique combination
SELECT DISTINCT category, brand FROM products;

11. UPDATE — แก้ไข

UPDATE แก้ข้อมูลที่มีอยู่ — ⚠️ กฎเหล็กคือ "อย่าลืม WHERE" เพราะลืมเมื่อไหร่จะอัปเดตทุกแถวในตาราง! ใน production ควรครอบด้วย transaction (BEGIN → ดู row count → COMMIT/ROLLBACK) เพื่อกันพลาด:

sql
-- ⚠️ มี WHERE = update เฉพาะ row ที่ตรงเงื่อนไข (ลืม WHERE = โดนทุก row!)
UPDATE users SET status = 'INACTIVE' WHERE last_login < '2024-01-01';

-- หลาย column
UPDATE users 
SET name = 'Anna B.',
    age = 26,
    updated_at = NOW()
WHERE id = 1;

-- จาก expression
UPDATE products SET price = price * 1.1 WHERE category = 'BOOK';

-- RETURNING
UPDATE orders SET status = 'PAID' WHERE id = 10
RETURNING id, total;

-- จาก subquery
UPDATE users 
SET status = 'PREMIUM'
WHERE id IN (
    SELECT user_id FROM orders WHERE total > 1000
);

⚠️ Safety — ใช้ Transaction

💡 คำสั่ง transaction (บทที่ 5 จะลึก) — ที่นี่แค่เป็น "safety trick" ก่อน:

  • BEGIN = เริ่ม transaction (กลุ่มคำสั่งที่ทำพร้อมกัน)
  • COMMIT = ยืนยัน — บันทึกผลถาวร
  • ROLLBACK = ยกเลิก — ย้อนทุกการเปลี่ยนแปลงตั้งแต่ BEGIN
sql
BEGIN;
UPDATE products SET price = price * 1.1 WHERE category = 'BOOK';
-- ดู row count, ถ้าผิด:
ROLLBACK;
-- ถ้าถูก:
COMMIT;

12. DELETE — ลบ

DELETE ลบแถวตามเงื่อนไข — เหมือน UPDATE คือลืม WHERE แล้วลบหมดทั้งตาราง การลบทั้งตารางด้วย DELETE ช้า (ทำทีละแถว + log) ถ้าต้องการ clear data ทั้งหมดให้ใช้ TRUNCATE ที่เร็วกว่ามาก:

sql
-- ลบเฉพาะที่
DELETE FROM users WHERE id = 1;
DELETE FROM users WHERE status = 'INACTIVE' AND last_login < '2020-01-01';

-- RETURNING
DELETE FROM users WHERE id = 1 RETURNING *;

-- ⚠️ ลบทั้ง table (slow — ทำ row by row + log)
DELETE FROM users;

-- เร็วกว่า (skip log + reset auto-increment) — ใช้เมื่อ clear test data
TRUNCATE TABLE users RESTART IDENTITY;

13. ตัวอย่างจริง — Bookstore

มารวมทุกคำสั่งในบท (CREATE/INSERT/SELECT/UPDATE/DELETE) เป็นตัวอย่างจริง — ร้านหนังสือที่มีตาราง authors/books พร้อมข้อมูลและ query ใช้งาน ลองพิมพ์ตามทีละคำสั่งจะเข้าใจ SQL พื้นฐานครบ:

sql
-- Schema
CREATE TABLE authors (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    country VARCHAR(50),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE books (
    id SERIAL PRIMARY KEY,
    title VARCHAR(300) NOT NULL,
    author_id INTEGER NOT NULL REFERENCES authors(id),
    isbn VARCHAR(20) UNIQUE,
    price NUMERIC(8, 2) NOT NULL CHECK (price >= 0),
    stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    published_year INTEGER,
    category VARCHAR(50),
    is_available BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Data
INSERT INTO authors (name, country) VALUES
    ('George Orwell', 'UK'),
    ('Haruki Murakami', 'Japan'),
    ('Margaret Atwood', 'Canada');

INSERT INTO books (title, author_id, isbn, price, stock, published_year, category) VALUES
    ('1984',                 1, '978-0-452-28423-4', 12.99, 50, 1949, 'FICTION'),
    ('Animal Farm',          1, '978-0-452-28424-1',  9.99, 30, 1945, 'FICTION'),
    ('Norwegian Wood',       2, '978-0-375-70402-4', 15.99, 20, 1987, 'FICTION'),
    ('Kafka on the Shore',   2, '978-1-400-07927-8', 17.99,  0, 2002, 'FICTION'),
    ('The Handmaid''s Tale', 3, '978-0-385-49081-8', 14.99, 15, 1985, 'FICTION');

-- Queries
-- 1. หนังสือทั้งหมดที่ราคา < 15
SELECT title, price FROM books WHERE price < 15 ORDER BY price;

-- 2. หนังสือที่ในสต็อก + เรียงตามปีพิมพ์
SELECT title, published_year, stock 
FROM books 
WHERE stock > 0 
ORDER BY published_year DESC;

-- 3. ค้นชื่อมี "the"
SELECT title FROM books WHERE title ILIKE '%the%';

-- 4. หนังสือที่หมดสต็อก
SELECT title FROM books WHERE stock = 0;

-- 5. อัพเดทราคา +10% สำหรับหนังสือก่อนปี 1990
UPDATE books SET price = ROUND(price * 1.10, 2) WHERE published_year < 1990;

-- 6. ลบหนังสือ unavailable + stock 0
DELETE FROM books WHERE is_available = FALSE AND stock = 0;

14. ⚠️ จุดที่พลาดบ่อย (Common Pitfalls)

"Pitfall" (พิทฟอลล์) = หลุมพราง / จุดที่คนทั่วไปพลาดบ่อย — ในเล่มนี้จะใช้คำนี้สม่ำเสมอ

จุดที่พลาดบ่อย (Pitfall)แก้
WHERE name = NULLWHERE name IS NULL
UPDATE/DELETE ไม่มี WHEREใช้ transaction + ดู count ก่อน
SELECT * ทุกที่ระบุ column — ลด data + clear intent
String concat (ต่อ string — ออกเสียง "คอน-แค็ท" ย่อจาก concatenate) ผ่าน +PostgreSQL ใช้ || หรือ CONCAT() — หมายเหตุ: ใน SQL || คือต่อ string ไม่ใช่ OR (OR ใช้คำว่า OR ตรง ๆ)
เทียบ String แบบ case-sensitive ('Anna''anna')ใช้ ILIKE หรือ LOWER()
ใช้ TIMESTAMP ไม่มี timezoneใช้ TIMESTAMPTZ
ใช้ FLOAT กับเงินใช้ NUMERIC
ลืม PRIMARY KEYทุก table ควรมี PK
Forget RETURNINGINSERT/UPDATE/DELETE มี RETURNING ใช้ได้

15. Checkpoint

🛠️ Checkpoint 1.1 — Library Schema
ออกแบบ + สร้าง schema สำหรับห้องสมุด:

  • members (id, email, name, joined_at)
  • books (id, title, author, isbn, copies_total, copies_available)
  • loans (id, member_id, book_id, borrowed_at, returned_at)

ใส่ constraint ที่เหมาะสม

🛠️ Checkpoint 1.2 — CRUD Practice
ใน table books:

  • Insert 20 books (หลาย category, ราคาต่าง)
  • Update ราคา discount 20% สำหรับ category 'FICTION'
  • Delete books ที่ stock = 0 และอายุ > 30 ปี
  • Find: 10 books ราคาแพงสุด + ชื่อมี "the"

🛠️ Checkpoint 1.3 — Tricky NULL
ลอง:

sql
SELECT * FROM books WHERE published_year != 2020;

ถ้า published_year มี NULL — เกิดอะไรขึ้น? อธิบาย + แก้


16. สรุปบท

✅ DDL = CREATE / ALTER / DROP / TRUNCATE
✅ DML = SELECT / INSERT / UPDATE / DELETE
✅ Data types ที่ใช้บ่อย: SERIAL, VARCHAR, TEXT, INTEGER, NUMERIC, BOOLEAN, TIMESTAMPTZ, JSONB
✅ Constraints: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY KEY, REFERENCES
✅ NULL handling: IS NULL ไม่ใช่ = NULL
✅ INSERT ระบุ column เสมอ + ใช้ RETURNING
✅ UPDATE/DELETE ใช้ transaction safety
✅ TIMESTAMPTZ (with timezone) > TIMESTAMP
✅ NUMERIC > FLOAT สำหรับเงิน


← บทที่ 0 | บทที่ 2 → JOIN + Relationships