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

Constraint

type ตัดสินว่า column เก็บค่าชนิดไหนได้ ส่วน constraint ตัดสินว่าค่าและการรวมกันแบบไหนถูกอนุญาตจริง ๆ constraint คือกฎที่เซิร์ฟเวอร์ตรวจทุกครั้งที่ insert และ update และปฏิเสธสิ่งที่ฝ่าฝืน เพราะฐานข้อมูลเป็นผู้บังคับใช้ constraint จึงมีผลไม่ว่าแอปพลิเคชันหรือคนใดจะเขียนข้อมูล — มันคือกระดูกสันหลังของความถูกต้องของข้อมูล

เราจะโมเดลร้านค้าเล็ก ๆ ด้วย customers และ orders เพื่อดู constraint แต่ละชนิดทำงาน และตั้งใจให้แต่ละอันล้มเหลวเพื่อให้คุณเห็น error จริงและรหัส SQLSTATE

ทุก output มาจากการรัน fence จริงบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) ในฐานข้อมูลเปล่าหนึ่งตัว จากบนลงล่าง ตัวเลขเวลามาจาก Mac เครื่องนี้

constraint NOT NULL ห้ามไม่ให้ไม่มีค่า column จึงต้องถูกเติมเสมอ DEFAULT ใส่ค่าให้เมื่อ insert ไม่ระบุ column นั้น สองอย่างนี้เข้าคู่กันโดยธรรมชาติ: default ที่สมเหตุสมผลบวก NOT NULL หมายความว่า column มีค่าเสมอโดยไม่ต้องบังคับให้ทุก insert เขียนออกมา

-- sql/customers.sql
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO customers (name) VALUES ('Nadia Frost'), ('Kofi Mensah');
SELECT id, name, created_at IS NOT NULL AS created_at_filled FROM customers;
CREATE TABLE
INSERT 0 2
id | name | created_at_filled
----+-------------+-------------------
1 | Nadia Frost | t
2 | Kofi Mensah | t
(2 rows)

ตรงนี้ name ต้องถูกระบุเสมอ ส่วน created_at เติมตัวเองด้วยเวลาปัจจุบันถ้าคุณไม่ระบุ การฝ่าฝืน NOT NULL ล้มเหลวด้วย SQLSTATE 23502 และ DETAIL แสดง row ที่ล้มเหลวทั้งแถว:

-- @expect-error 23502
INSERT INTO customers (name) VALUES (NULL);
ERROR: null value in column "name" of relation "customers" violates not-null constraint
DETAIL: Failing row contains (3, null, 2026-09-30 06:07:40.143206+00).

สังเกตเลข 3 ใน row นั้น: insert ที่ล้มเหลวก็ยังใช้ค่า identity ไปหนึ่งค่า (บทก่อนหน้า) ลูกค้าคนถัดไปจึงเป็น 4

PRIMARY KEY ระบุ column (หรือหลาย column) ที่ระบุ row ได้แบบไม่ซ้ำ เป็นชวเลขของ NOT NULL บวก UNIQUE และ table มีได้เพียงหนึ่งตัว column id ข้างบนคือ primary key ของ customers การบังคับให้ซ้ำ (ด้วยทางหนี OVERRIDING SYSTEM VALUE เพราะ identity column ไม่มีทางสร้างค่าซ้ำเอง) ล้มเหลวด้วย 23505:

-- @expect-error 23505
INSERT INTO customers (id, name) OVERRIDING SYSTEM VALUE VALUES (1, 'Impostor');
ERROR: duplicate key value violates unique constraint "customers_pkey"
DETAIL: Key (id)=(1) already exists.

constraint UNIQUE ห้ามค่าซ้ำใน column ใช้แสดงกฎอย่าง “ลูกค้าสองคนใช้อีเมลเดียวกันไม่ได้” โดยไม่ต้องทำให้ column นั้นเป็น primary key การตั้งชื่อ constraint customers_email_unique เป็นทางเลือกแต่คุ้มค่า: ข้อความ error และ ALTER TABLE ภายหลังอ้างถึงมันด้วยชื่อแทน label ที่สร้างอัตโนมัติ มันถูกบังคับด้วย index ซึ่ง \d แสดงใต้ Indexes unique constraint จึงมีต้นทุนการเขียนของ B-tree index

-- sql/unique-email.sql
ALTER TABLE customers
ADD COLUMN email text,
ADD CONSTRAINT customers_email_unique UNIQUE (email);
UPDATE customers SET email = '[email protected]' WHERE id = 1;
INSERT INTO customers (name) VALUES ('Ilse Brandt'), ('Tomas Weiss');
SELECT id, name, email FROM customers ORDER BY id;
\d customers
ALTER TABLE
UPDATE 1
INSERT 0 2
id | name | email
----+-------------+-------------------
1 | Nadia Frost | [email protected]
2 | Kofi Mensah |
4 | Ilse Brandt |
5 | Tomas Weiss |
(4 rows)
Table "public.customers"
Column | Type | Collation | Nullable | Default
------------+--------------------------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
name | text | | not null |
created_at | timestamp with time zone | | not null | now()
email | text | | |
Indexes:
"customers_pkey" PRIMARY KEY, btree (id)
"customers_email_unique" UNIQUE CONSTRAINT, btree (email)

ดู column email: ลูกค้าสามคนเป็น NULL และไม่มี error โดยค่าเริ่มต้นค่า NULL ถือว่าแตกต่างกัน UNIQUE จึงอนุญาตกี่ตัวก็ได้ ส่วนค่าซ้ำจริงล้มเหลว:

-- @expect-error 23505
UPDATE customers SET email = '[email protected]' WHERE id = 2;
ERROR: duplicate key value violates unique constraint "customers_email_unique"
DETAIL: Key (email)=([email protected]) already exists.

ถ้าต้องการ “NULL ได้อย่างมากหนึ่งตัว” PostgreSQL 15 เพิ่ม UNIQUE NULLS NOT DISTINCT:

-- sql/nulls-not-distinct.sql
CREATE TABLE members (
email text,
CONSTRAINT members_email_key UNIQUE NULLS NOT DISTINCT (email)
);
INSERT INTO members VALUES (NULL);
-- @expect-error 23505
INSERT INTO members VALUES (NULL);
ERROR: duplicate key value violates unique constraint "members_email_key"
DETAIL: Key (email)=(null) already exists.

constraint CHECK ทดสอบนิพจน์กับแต่ละ row และปฏิเสธการเขียนถ้าผลเป็นเท็จ ใช้เข้ารหัสกฎทางธุรกิจที่ type อย่างเดียวแสดงไม่ได้ เช่นราคาที่ต้องเป็นบวก หรือส่วนลดที่เกินยอดรวมไม่ได้ (check อ้างถึงหลาย column ใน row เดียวกันได้)

-- sql/orders.sql
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
total numeric(10, 2) NOT NULL,
discount numeric(10, 2),
CONSTRAINT orders_total_positive CHECK (total > 0),
CONSTRAINT orders_discount_valid CHECK (discount >= 0 AND discount <= total)
);

การ insert order ที่ total เป็นศูนย์หรือติดลบตอนนี้ล้มเหลวก่อนเก็บ row ด้วย 23514 เช่นเดียวกับส่วนลดที่มากกว่ายอดรวม:

-- @expect-error 23514
INSERT INTO orders (customer_id, total) VALUES (1, 0);
ERROR: new row for relation "orders" violates check constraint "orders_total_positive"
DETAIL: Failing row contains (1, 1, 0.00, null).
-- @expect-error 23514
INSERT INTO orders (customer_id, total, discount) VALUES (1, 10.00, 15.00);
ERROR: new row for relation "orders" violates check constraint "orders_discount_valid"
DETAIL: Failing row contains (2, 1, 10.00, 15.00).

ข้อละเอียดอ่อนหนึ่งข้อ: CHECK ปฏิเสธเมื่อนิพจน์เป็น เท็จ เท่านั้น เมื่อผลเป็น NULL (“ไม่ทราบ”) row ผ่าน column discount ไม่มี NOT NULL ส่วนลด NULL จึงผ่าน discount >= 0 ไปได้ (คุณจะเห็น row แบบนี้ใน output ถัดไป) ถ้า NULL ควรถูกปฏิเสธ ให้บอกด้วย NOT NULL

FOREIGN KEY ผูก column กับ key ในอีก table รับประกันว่าทุก customer_id ใน orders ชี้ไปที่ row จริงใน customers นี่คือ referential integrity: ฐานข้อมูลจะไม่ให้คุณสร้าง order ให้ลูกค้าที่ไม่มีอยู่ หรือทิ้ง order ที่ชี้ไปยังลูกค้าที่คุณลบ

ถ้าไม่มี constraint ไม่มีอะไรห้าม order ของลูกค้า 999 (ไม่มีลูกค้าคนนั้น) row นั้นค้างอยู่เป็น orphan จนกว่าจะมีคนสะดุด:

-- sql/orders-before-fk.sql
INSERT INTO orders (customer_id, total) VALUES (999, 20.00);
SELECT id, customer_id, total FROM orders;
DELETE FROM orders WHERE customer_id = 999;
INSERT 0 1
id | customer_id | total
----+-------------+-------
3 | 999 | 20.00
(1 row)
DELETE 1

ทีนี้เพิ่ม constraint มันใช้เป็น migration จาก driver ได้ด้วย — SQL เดียวกัน ต่างกันแค่วิธีส่ง:

-- sql/orders-fk.sql
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id)
ON DELETE CASCADE;
INSERT INTO orders (customer_id, total, discount) VALUES (1, 30.00, 5.00), (1, 12.50, NULL), (2, 99.00, 0);
SELECT id, customer_id, total, discount FROM orders ORDER BY id;
ALTER TABLE
INSERT 0 3
id | customer_id | total | discount
----+-------------+-------+----------
4 | 1 | 30.00 | 5.00
5 | 1 | 12.50 |
6 | 2 | 99.00 | 0.00
(3 rows)
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id)
ON DELETE CASCADE;

แต่ละ snippet ของ driver รันหนึ่งครั้งกับฐานข้อมูลชั่วคราวที่มีสอง table อยู่แล้ว แล้วถาม catalog ว่าสร้างอะไร (c หมายถึง ON DELETE CASCADE):

$ node app.mjs # pg 8.23.0 (confdeltype 'c' means ON DELETE CASCADE)
{ confdeltype: 'c' }
$ python app.py # psycopg 3.3.6
('c',)
$ go run . # pgx v5.11.0
confdeltype: c
$ cargo run # sqlx 0.9.0
confdeltype: c

ใน pgAdmin: constraint ของ table ปรากฏใต้โหนด Constraints ใน browser tree และกล่อง Properties มีแท็บสำหรับ primary key, foreign key, check และ unique constraint ถ้าคุณชอบฟอร์มมากกว่า SQL

order ของลูกค้าที่ไม่มีอยู่ตอนนี้ถูกปฏิเสธด้วย 23503:

-- @expect-error 23503
INSERT INTO orders (customer_id, total) VALUES (999, 20.00);
ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_fk"
DETAIL: Key (customer_id)=(999) is not present in table "customers".

ตอนนี้สอง 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
orders.customer_id อ้างอิง customers.id

การเพิ่ม constraint ตรวจ row ที่มีอยู่ก่อน ถ้ามี row ใดฝ่าฝืน ALTER ล้มเหลวและไม่มีอะไรถูกเพิ่ม (order 99.00 เกินขีดจำกัด):

-- @expect-error 23514
ALTER TABLE orders ADD CONSTRAINT orders_small CHECK (total < 50);
ERROR: check constraint "orders_small" of relation "orders" is violated by some row

ทำความสะอาดข้อมูลก่อน แล้วค่อยเพิ่ม constraint บน table ใหญ่มีวิธีสองขั้น NOT VALID / VALIDATE CONSTRAINT ที่เลี่ยง lock ยาว บท altering-schema แสดงไว้

clause ON DELETE ตัดสินว่าเกิดอะไรกับ row ที่พึ่งพาเมื่อ row ที่ถูกอ้างอิงถูกลบ orders_customer_fk ข้างบนใช้ CASCADE: การลบลูกค้า 1 ลบ order ของเขาด้วย (rollback ไว้ที่นี่เพื่อให้ข้อมูลอยู่):

-- sql/on-delete-cascade.sql
BEGIN;
SELECT count(*) AS orders_before FROM orders;
DELETE FROM customers WHERE id = 1 RETURNING id, name;
SELECT count(*) AS orders_left FROM orders;
ROLLBACK;
BEGIN
orders_before
---------------
3
(1 row)
id | name
----+-------------
1 | Nadia Frost
(1 row)
DELETE 1
orders_left
-------------
1
(1 row)
ROLLBACK

มีห้าตัวเลือก: NO ACTION (ค่าเริ่มต้น), RESTRICT, CASCADE, SET NULL, SET DEFAULT เลือก CASCADE เมื่อ row ลูกไม่มีความหมายถ้าไม่มี row แม่, SET NULL เมื่อลูกควรอยู่รอดแบบไม่เชื่อมโยง และ RESTRICT หรือ NO ACTION เมื่อต้องการเครื่องกันการสูญหายโดยไม่ตั้งใจ พฤติกรรมจริงของสามตัว:

-- sql/on-delete-actions.sql
CREATE TABLE teams (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE p_restrict (id int PRIMARY KEY, team_id int REFERENCES teams ON DELETE RESTRICT);
CREATE TABLE p_noaction (id int PRIMARY KEY, team_id int REFERENCES teams ON DELETE NO ACTION);
CREATE TABLE p_setnull (id int PRIMARY KEY, team_id int REFERENCES teams ON DELETE SET NULL);
INSERT INTO teams VALUES (1, 'Harbor'), (2, 'Orchard'), (3, 'Tide');
INSERT INTO p_restrict VALUES (1, 1);
INSERT INTO p_noaction VALUES (1, 2);
INSERT INTO p_setnull VALUES (1, 3);
DELETE FROM teams WHERE id = 3;
SELECT * FROM p_setnull;
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 1
INSERT 0 1
INSERT 0 1
DELETE 1
id | team_id
----+---------
1 |
(1 row)

SET NULL ทิ้ง row ลูกไว้พร้อม team_id ว่าง ส่วน RESTRICT และ NO ACTION ปฏิเสธการลบทั้งคู่ แต่ด้วยรหัส SQLSTATE และถ้อยคำต่างกัน:

-- @expect-error 23001
DELETE FROM teams WHERE id = 1;
ERROR: update or delete on table "teams" violates RESTRICT setting of foreign key constraint "p_restrict_team_id_fkey" on table "p_restrict"
DETAIL: Key (id)=(1) is referenced from table "p_restrict".
-- @expect-error 23503
DELETE FROM teams WHERE id = 2;
ERROR: update or delete on table "teams" violates foreign key constraint "p_noaction_team_id_fkey" on table "p_noaction"
DETAIL: Key (id)=(2) is still referenced from table "p_noaction".

สองตัวนี้เทียบเท่ากันหรือไม่? ไม่ทั้งหมด ความต่างปรากฏกับ constraint แบบ DEFERRABLE NO ACTION ถูกตรวจตอนท้าย statement — หรือถ้า constraint เป็น DEFERRABLE INITIALLY DEFERRED ตอน COMMIT คุณจึงทำลายการอ้างอิงชั่วคราวแล้วซ่อมภายใน transaction ได้ ส่วน RESTRICT ถูกตรวจทันทีเสมอและ defer ไม่ได้ (docs) พิสูจน์ด้วย constraint สองตัวที่เหมือนกันทุกอย่างยกเว้น action:

-- sql/deferred-no-action.sql
CREATE TABLE d_noaction (id int PRIMARY KEY, team_id int,
FOREIGN KEY (team_id) REFERENCES teams ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED);
CREATE TABLE d_restrict (id int PRIMARY KEY, team_id int,
FOREIGN KEY (team_id) REFERENCES teams ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED);
INSERT INTO teams VALUES (10, 'Anchor'), (11, 'Beacon');
INSERT INTO d_noaction VALUES (1, 10);
INSERT INTO d_restrict VALUES (1, 11);
BEGIN;
DELETE FROM teams WHERE id = 10; -- child still points at 10: no error yet
INSERT INTO teams VALUES (10, 'Anchor'); -- repaired before COMMIT
COMMIT;
SELECT * FROM d_noaction;
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 1
INSERT 0 1
BEGIN
DELETE 1
INSERT 0 1
COMMIT
id | team_id
----+---------
1 | 10
(1 row)
-- @expect-error 23001
BEGIN;
DELETE FROM teams WHERE id = 11;
BEGIN
ERROR: update or delete on table "teams" violates RESTRICT setting of foreign key constraint "d_restrict_team_id_fkey" on table "d_restrict"
DETAIL: Key (id)=(11) is referenced from table "d_restrict".

PostgreSQL ทำ index ให้ column ที่ถูกอ้างอิง (เป็น primary key หรือ unique) แต่ ไม่ ทำให้ column ที่อ้างอิง การลบ row แม่ (หรือเปลี่ยน key ของมัน) ทำให้เซิร์ฟเวอร์ต้องหา row ลูก ถ้าไม่มี index บน column ลูกก็สแกนทั้ง table ลูก สอง table ลูกที่มี 200,000 row เท่ากัน ตัวหนึ่งมี index บน parent_id ลบ row แม่ที่ไม่มีลูก แล้วอ่านเวลา trigger ใน EXPLAIN ANALYZE (rollback หลังจากนั้น):

-- sql/fk-index-cost.sql
CREATE TABLE parents (id int PRIMARY KEY);
CREATE TABLE kids_plain (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id int NOT NULL REFERENCES parents (id)
);
CREATE TABLE kids_indexed (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id int NOT NULL REFERENCES parents (id)
);
CREATE INDEX kids_indexed_parent_idx ON kids_indexed (parent_id);
INSERT INTO parents SELECT g FROM generate_series(1, 1000) g;
INSERT INTO kids_plain (parent_id) SELECT 1 + (g % 100) FROM generate_series(1, 200000) g;
INSERT INTO kids_indexed (parent_id) SELECT parent_id FROM kids_plain;
ANALYZE kids_plain, kids_indexed;
-- sql/fk-index-explain.sql
BEGIN;
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF, SUMMARY OFF) DELETE FROM parents WHERE id = 1000;
ROLLBACK;
BEGIN
QUERY PLAN
---------------------------------------------------------------------------------------------
Delete on parents (actual time=0.064..0.065 rows=0.00 loops=1)
-> Index Scan using parents_pkey on parents (actual time=0.013..0.013 rows=1.00 loops=1)
Index Cond: (id = 1000)
Index Searches: 1
Trigger for constraint kids_plain_parent_id_fkey: time=6.787 calls=1
Trigger for constraint kids_indexed_parent_id_fkey: time=0.154 calls=1
(6 rows)
ROLLBACK

วัดบน Mac เครื่องนี้: ประมาณ 6.8 ms เมื่อไม่มี index และ 0.15 ms เมื่อมี — ราว 45 เท่า สำหรับการลบที่ไม่แม้แต่เจอ row ลูก มันโตตาม table ลูกและมีผลกับทุกการลบและการเปลี่ยน key ของ row แม่ เพิ่ม index บน column FK เว้นแต่ table ลูกเล็กมากหรือ row แม่ไม่เคยถูกลบ

ผู้ช่วย AI ที่ถูกขอให้ “บังคับใน schema ว่า order สั่งได้เฉพาะลูกค้าที่ active” เขียนสิ่งนี้ และดูเหมือนสิ่งที่คุณต้องการเป๊ะ:

🐛 หา bug ในโค้ดนี้

-- AI comment: "orders may only be placed for active customers - enforce it in the schema"
CREATE FUNCTION customer_is_active(cid bigint) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT active FROM customers WHERE id = cid $$;
ALTER TABLE orders
ADD CONSTRAINT orders_customer_must_be_active
CHECK (customer_is_active(customer_id));
เฉลย

PostgreSQL ไม่ยอมให้ CHECK มี subquery ผู้ช่วยจึงซ่อนการค้นไว้ในฟังก์ชัน และเซิร์ฟเวอร์ก็รับ นั่นคือกับดัก: คู่มือระบุว่า PostgreSQL ไม่รองรับ CHECK constraint ที่อ้างถึงข้อมูลใน table นอกจาก row ที่ใหม่หรือถูกอัปเดต (docs) รูปแบบตรง ๆ ถูกปฏิเสธ แต่รูปแบบที่ห่อไว้ผ่าน:

-- @expect-error 0A000
CREATE TABLE cx (customer_id bigint, CHECK (EXISTS (SELECT 1 FROM customers WHERE id = customer_id)));
ERROR: cannot use subquery in check constraint
LINE 1: CREATE TABLE cx (customer_id bigint, CHECK (EXISTS (SELECT 1...
^
-- sql/check-function.sql
CREATE TABLE ck_customers (id bigint PRIMARY KEY, active boolean NOT NULL DEFAULT true);
INSERT INTO ck_customers VALUES (1, true), (2, false);
CREATE FUNCTION customer_is_active(cid bigint) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT active FROM ck_customers WHERE id = cid $$;
CREATE TABLE ck_orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES ck_customers (id),
total numeric(10, 2) NOT NULL,
CONSTRAINT orders_customer_must_be_active CHECK (customer_is_active(customer_id))
);
INSERT INTO ck_orders (customer_id, total) VALUES (1, 10.00);

check ทำงานเฉพาะตอน insert หรือ update row ของ order order ใหม่ของลูกค้าที่ไม่ active ถูกปฏิเสธ:

-- @expect-error 23514
INSERT INTO ck_orders (customer_id, total) VALUES (2, 10.00);
ERROR: new row for relation "ck_orders" violates check constraint "orders_customer_must_be_active"
DETAIL: Failing row contains (2, 2, 10.00).

แต่ต่อมาลูกค้าเปลี่ยน และ check ไม่ถูกรันอีก order ฝ่าฝืนกฎของตัวเองแบบเงียบ ๆ:

-- sql/check-drift.sql
UPDATE ck_customers SET active = false WHERE id = 1;
SELECT o.id, o.customer_id, customer_is_active(o.customer_id) AS rule_holds_now FROM ck_orders o;
UPDATE 1
id | customer_id | rule_holds_now
----+-------------+----------------
1 | 1 | f
(1 row)

แย่กว่านั้น กฎนี้ตอนนี้ขวางการ update ที่ไม่เกี่ยวข้องของ row นั้น เพราะทุก update ตรวจ row ทั้งแถวใหม่:

-- @expect-error 23514
UPDATE ck_orders SET total = total + 1 WHERE id = 1;
ERROR: new row for relation "ck_orders" violates check constraint "orders_customer_must_be_active"
DETAIL: Failing row contains (1, 1, 11.00).

การ implement กฎ “order สั่ง ได้เฉพาะลูกค้าที่ active” อย่างซื่อตรงคือกฎ ณ เวลา insert จึงใช้ trigger (หรือแอปพลิเคชัน) และเก็บ foreign key ไว้สำหรับสิ่งที่ constraint รับประกันได้:

-- sql/check-trigger.sql
CREATE TABLE ck_orders2 (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES ck_customers (id),
total numeric(10, 2) NOT NULL
);
CREATE FUNCTION require_active_customer() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT customer_is_active(NEW.customer_id) THEN
RAISE EXCEPTION 'customer % is not active', NEW.customer_id;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER ck_orders2_active BEFORE INSERT ON ck_orders2
FOR EACH ROW EXECUTE FUNCTION require_active_customer();
UPDATE ck_customers SET active = true WHERE id = 1;
INSERT INTO ck_orders2 (customer_id, total) VALUES (1, 10.00);
UPDATE ck_customers SET active = false WHERE id = 1;
UPDATE ck_orders2 SET total = total + 1;
-- @expect-error P0001
INSERT INTO ck_orders2 (customer_id, total) VALUES (1, 5.00);
ERROR: customer 1 is not active
CONTEXT: PL/pgSQL function require_active_customer() line 4 at RAISE

constraint คือ predicate เกี่ยวกับ row เดียว (NOT NULL, CHECK) ชุดของ row (PRIMARY KEY, UNIQUE) หรือสอง table (FOREIGN KEY) เซิร์ฟเวอร์ประเมินมันเมื่อ row ที่เกี่ยวข้องถูกเขียน ไม่ใช่เมื่อ row อื่นเปลี่ยน — ยกเว้น foreign key ที่เซิร์ฟเวอร์บังคับด้วย trigger ภายในบนทั้งสอง table นั่นคือเหตุผลที่ CHECK แสดงกฎเกี่ยวกับ table อื่นไม่ได้ ทำไม foreign key มีต้นทุนบน column ลูก และทำไมจังหวะการตรวจ (ทันทีหรือเลื่อน) จึงเป็นคุณสมบัติที่คุณเลือกได้

  • foreign key บังคับ referential integrity ให้คุณ ฐานข้อมูลจึงไม่เคยมี order ของลูกค้าที่ไม่มีอยู่ การพึ่งโค้ดแอปพลิเคชันอย่างเดียวเพื่อรักษาความถูกต้องของการอ้างอิงท้ายที่สุดจะล้มเหลว
  • ตั้งชื่อ constraint ให้ชัดเจน เช่น orders_total_positive แทนที่จะรับชื่อที่สร้างอัตโนมัติ ชื่อที่ชัดทำให้ข้อความ error อ่านง่ายและการ ALTER TABLE ภายหลังตรงไปตรงมา
  • เลือกพฤติกรรม ON DELETE อย่างตั้งใจ CASCADE ทำความสะอาด row ที่พึ่งพาอัตโนมัติ แต่อาจลบมากกว่าที่คิด RESTRICT หรือค่าเริ่มต้น NO ACTION ปลอดภัยกว่าเมื่อไม่แน่ใจ
  • CHECK อ้างถึงหลาย column ใน row เดียวกันได้ แต่ดู row อื่นหรือ table อื่นไม่ได้ (ฟังก์ชันที่ทำเช่นนั้นจะไม่ถูกบังคับเมื่อข้อมูลอื่นเปลี่ยน)
  • CHECK ผ่านเมื่อผลเป็น NULL เพิ่ม NOT NULL ถ้านั่นไม่ใช่สิ่งที่คุณต้องการ
  • สร้าง index บน column ที่อ้างอิงของ foreign key
  • การเพิ่ม constraint ให้ table ที่มีข้อมูลอยู่แล้วจะล้มเหลวถ้า row ที่มีอยู่ฝ่าฝืน ทำความสะอาดข้อมูลก่อน แล้วค่อยเพิ่ม constraint
ตัวเลือกBenefitCost
บังคับ invariant ด้วย constraint ของฐานข้อมูล (CHECK, UNIQUE, FOREIGN KEY)ถูกต้องเสมอ ไม่ว่า client หรือ service ใดเขียนข้อมูลการเปลี่ยนกฎภายหลังต้อง migration และอาจกระทบ row ที่มีอยู่
validate ในโค้ดแอปพลิเคชันอย่างเดียวเปลี่ยนกฎเร็ว ไม่ต้องแตะ schemaข้อมูลเสียหลุดเข้ามาง่ายผ่านสคริปต์ดิบ service อื่น หรือ migration ที่ลืมตรวจ
ON DELETE CASCADEไม่มี orphan ไม่ต้องเขียนโค้ดทำความสะอาดการลบครั้งเดียวลบได้หลาย row (ดูจำนวนข้างบน)
ON DELETE RESTRICT / NO ACTIONไม่มีอะไรหายโดยไม่ตั้งใจผู้เรียกต้องลบ row ลูกก่อน
  • “validate ในแอปก็พอ” สคริปต์ ad-hoc, batch job หรือ service อื่นที่เขียนตรงเข้าฐานข้อมูลข้ามมันได้เสมอ constraint ระดับฐานข้อมูลคือแนวป้องกันสุดท้าย
  • “NO ACTION กับ RESTRICT เหมือนกัน” ต่างกันเมื่อ constraint เป็น deferrable: NO ACTION รอถึง COMMIT ได้ RESTRICT ไม่ได้ (พิสูจน์ข้างบน) และรหัส error ต่างกัน (23503 กับ 23001)
  • “column unique เก็บ NULL ได้ตัวเดียว” โดยค่าเริ่มต้นเก็บได้กี่ตัวก็ได้ NULLS NOT DISTINCT (PostgreSQL 15+) เปลี่ยนสิ่งนั้น
  • “CHECK เรียกฟังก์ชันเพื่อดู table อื่นได้” ทำได้ แต่ประเมินเฉพาะเมื่อ row นี้เปลี่ยน กฎจึงไหลเลื่อนแบบเงียบ ๆ (พิสูจน์ข้างบน)
  • “PostgreSQL ทำ index ให้ foreign key” ทำให้เฉพาะฝั่งที่ถูกอ้างอิง column ลูกต้องการ index ของคุณ (การลบ row แม่เร็วขึ้นราว 45 เท่าในการวัดข้างบน)
constraint FOREIGN KEY รับประกันอะไร?
เมื่อ orders.customer_id มี ON DELETE CASCADE จะเกิดอะไรขึ้นเมื่อลูกค้าที่ถูกอ้างอิงถูกลบ?
constraint ใดปฏิเสธ row เมื่อนิพจน์ที่กำหนดเองประเมินเป็นเท็จ?
column หนึ่งมี UNIQUE และ insert NULL สามแถว จะเกิดอะไรขึ้น?
ข้อใดจริงกับ foreign key แบบ DEFERRABLE INITIALLY DEFERRED?