自訂型別:ENUM、Domain、Range 與複合型別 | PostgreSQL
2026/06/28
PostgreSQL 擁有業界最強大的型別可擴展性。透過 Composite Type、Enum Type、Range Type、Domain 四種自訂型別形式,加上自訂運算子與 Operator Class 索引支援,你可以從根本延伸資料庫的語義能力,讓資料模型精確對應業務領域。
型別系統的可擴展性
PostgreSQL 的型別系統建立在系統目錄之上,每個型別、運算子、索引類別都是資料庫物件,可以動態新增:
PostgreSQL 型別擴展架構
pg_type — 型別定義(OID、輸入/輸出函式、儲存策略)
pg_operator — 運算子定義(符號、左右運算元型別、底層函式)
pg_opclass — Operator Class(索引方法如何使用運算子)
pg_cast — 型別轉換規則(隱式/明確/賦值)
pg_enum — Enum 型別的列舉值
pg_range — Range 型別的範圍定義
四種自訂型別形式:
| 形式 | 語法 | 適用情境 |
|---|---|---|
| Composite Type | CREATE TYPE ... AS (...) | 多欄位複合結構、函式回傳值 |
| Enum Type | CREATE TYPE ... AS ENUM (...) | 固定有限集合的分類值 |
| Range Type | CREATE TYPE ... AS RANGE (...) | 連續值的區間(時間段、數值範圍) |
| Domain | CREATE DOMAIN ... AS ... | 既有型別加上約束的包裝 |
Composite Type(複合型別)
Composite Type 將多個欄位組合為單一型別,類似程式語言中的結構體(struct):
-- 建立地址複合型別
CREATE TYPE address_t AS (
street TEXT,
city TEXT,
postal_code VARCHAR(10),
country CHAR(2)
);
-- 建立貨幣金額複合型別
CREATE TYPE money_amount AS (
amount NUMERIC(15,4),
currency CHAR(3) -- ISO 4217:TWD、USD
);
-- 在資料表中使用
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
billing address_t,
shipping address_t
);
-- 插入資料(使用 ROW 建構子)
INSERT INTO customers (name, billing, shipping)
VALUES (
'王小明',
ROW('中山北路一段1號', '台北市', '10401', 'TW'),
ROW('信義路五段7號', '台北市', '11049', 'TW')
);
-- 存取子欄位(注意:必須加括號)
SELECT name, (billing).city, (billing).postal_code
FROM customers;
-- 在 WHERE 條件中使用
SELECT name FROM customers
WHERE (billing).country = 'TW';
-- 更新子欄位
UPDATE customers
SET billing.city = '新北市'
WHERE id = 1;
作為函式參數與回傳值
CREATE OR REPLACE FUNCTION format_address(addr address_t)
RETURNS TEXT AS $$
BEGIN
RETURN (addr).street || ', ' || (addr).city || ' '
|| (addr).postal_code || ', ' || (addr).country;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- 呼叫
SELECT format_address(billing) FROM customers WHERE id = 1;
-- 結果: 中山北路一段1號, 台北市 10401, TW
-- 解構複合型別回傳值
SELECT (get_primary_address(1)).*;
Enum Type(列舉型別)
Enum Type 定義固定有序的值集合,適合狀態機、分類標籤:
CREATE TYPE order_status AS ENUM (
'pending',
'confirmed',
'processing',
'shipped',
'delivered',
'cancelled'
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);
排序行為
Enum 值按定義順序排序,而非字母順序:
SELECT unnest(enum_range(NULL::order_status)) AS status;
-- 結果: pending → confirmed → processing → shipped → delivered → cancelled
-- 利用排序特性查詢「已進入處理階段」的訂單
SELECT * FROM orders
WHERE status >= 'processing'::order_status
ORDER BY status;
-- 比較運算
SELECT 'delivered'::order_status > 'pending'::order_status;
-- 結果: true
新增與修改 Enum 值
-- 在末尾新增
ALTER TYPE order_status ADD VALUE 'refunded';
-- 在特定位置插入
ALTER TYPE order_status ADD VALUE 'payment_pending' BEFORE 'confirmed';
-- 安全新增(已存在不報錯)
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'refunded';
重要限制:
-- 限制 1:同一交易內新增的值無法立即使用
BEGIN;
ALTER TYPE order_status ADD VALUE 'archived';
SELECT 'archived'::order_status; -- ERROR: unsafe use of new value
COMMIT;
-- 必須 COMMIT 後才能使用新值
-- 限制 2:無法刪除 Enum 值(沒有 DROP VALUE 語法)
-- 唯一方式:重建整個型別
BEGIN;
CREATE TYPE order_status_new AS ENUM (
'pending', 'confirmed', 'processing',
'shipped', 'delivered', 'cancelled', 'refunded'
);
ALTER TABLE orders
ALTER COLUMN status TYPE order_status_new
USING status::TEXT::order_status_new;
DROP TYPE order_status;
ALTER TYPE order_status_new RENAME TO order_status;
COMMIT;
Range Type(範圍型別)
Range Type 原生表示連續值的區間,支援包含/排除端點與無限端點:
內建範圍型別
| 型別 | 子型別 | 說明 |
|---|---|---|
int4range | integer | 整數範圍 |
int8range | bigint | 大整數範圍 |
numrange | numeric | 精確數值範圍 |
tsrange | timestamp | 時間戳記範圍 |
tstzrange | timestamptz | 帶時區時間戳記範圍 |
daterange | date | 日期範圍 |
-- 建立範圍([] 包含端點,() 排除端點)
SELECT '[2026-01-01, 2026-12-31]'::daterange; -- 包含兩端
SELECT '[2026-01-01, 2026-12-31)'::daterange; -- 排除右端(日期常用)
SELECT '(0, 100)'::numrange; -- 排除兩端
SELECT '[2026-01-01,)'::daterange; -- 到無限遠
-- 範圍運算子
SELECT '[1,10]'::int4range @> 5; -- 包含:true
SELECT '[1,5]'::int4range && '[3,8]'::int4range; -- 重疊:true
SELECT '[1,5]'::int4range * '[3,8]'::int4range; -- 交集:[3,6)
SELECT '[1,5]'::int4range + '[4,8]'::int4range; -- 聯集:[1,9)
排他約束(Exclusion Constraint)
Range Type 最強大的應用是搭配排他約束防止重疊:
-- 會議室預訂:防止同一房間時段重疊
CREATE TABLE room_bookings (
id SERIAL PRIMARY KEY,
room_id INT NOT NULL,
booked_by TEXT NOT NULL,
during tstzrange NOT NULL,
-- 排他約束:同一房間的時段不可重疊
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
-- 成功預訂
INSERT INTO room_bookings (room_id, booked_by, during)
VALUES (101, 'Alice', '[2026-06-28 09:00, 2026-06-28 11:00)');
-- 衝突預訂(自動拒絕)
INSERT INTO room_bookings (room_id, booked_by, during)
VALUES (101, 'Bob', '[2026-06-28 10:00, 2026-06-28 12:00)');
-- ERROR: conflicting key value violates exclusion constraint
-- 查詢特定時段的預訂
SELECT * FROM room_bookings
WHERE room_id = 101
AND during && '[2026-06-28 09:00, 2026-06-28 11:00)'::tstzrange;
Domain(受限域型別)
Domain 是在既有型別上加上 CHECK Constraint 的包裝,提供語義化的型別別名與資料驗證:
-- Email 地址驗證
CREATE DOMAIN email_address AS TEXT
CHECK (VALUE ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
-- 正整數
CREATE DOMAIN positive_int AS INTEGER
CHECK (VALUE > 0);
-- 百分比(0~100)
CREATE DOMAIN percentage AS NUMERIC(5,2)
CHECK (VALUE >= 0 AND VALUE <= 100);
-- 台灣手機號碼
CREATE DOMAIN tw_mobile AS VARCHAR(10)
CHECK (VALUE ~ '^09[0-9]{8}$');
-- ISO 4217 貨幣代碼
CREATE DOMAIN currency_code AS CHAR(3)
CHECK (VALUE ~ '^[A-Z]{3}$');
-- 帶預設值的 Domain
CREATE DOMAIN active_flag AS BOOLEAN
NOT NULL DEFAULT TRUE;
-- 在資料表中使用
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
email email_address NOT NULL UNIQUE,
mobile tw_mobile,
salary positive_int NOT NULL,
bonus_pct percentage DEFAULT 0,
is_active active_flag
);
-- Domain 約束在 INSERT 時自動驗證
INSERT INTO employees (email, mobile, salary)
VALUES ('invalid-email', '0912345678', 50000);
-- ERROR: value for domain email_address violates check constraint
-- 修改 Domain 約束
ALTER DOMAIN percentage DROP CONSTRAINT percentage_check;
ALTER DOMAIN percentage ADD CONSTRAINT pct_range CHECK (VALUE >= 0 AND VALUE <= 100);
CREATE OPERATOR(自訂運算子)
自訂運算子讓使用者以符號語法表達自訂型別的操作:
-- 底層比較函式
CREATE OR REPLACE FUNCTION money_amount_lt(a money_amount, b money_amount)
RETURNS BOOLEAN AS $$
BEGIN
IF (a).currency != (b).currency THEN
RAISE EXCEPTION '無法比較不同幣別:% 與 %',
(a).currency, (b).currency;
END IF;
RETURN (a).amount < (b).amount;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
CREATE OR REPLACE FUNCTION money_amount_eq(a money_amount, b money_amount)
RETURNS BOOLEAN AS $$
BEGIN
RETURN (a).currency = (b).currency AND (a).amount = (b).amount;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
-- 建立運算子
CREATE OPERATOR < (
LEFTARG = money_amount,
RIGHTARG = money_amount,
PROCEDURE = money_amount_lt,
COMMUTATOR = >, -- 交換律對應
NEGATOR = >= -- 否定運算子
);
CREATE OPERATOR = (
LEFTARG = money_amount,
RIGHTARG = money_amount,
PROCEDURE = money_amount_eq,
COMMUTATOR = =,
NEGATOR = <>,
HASHES, MERGES
);
-- 使用自訂運算子
SELECT ROW(100.00, 'TWD')::money_amount < ROW(200.00, 'TWD')::money_amount;
-- 結果: true
Operator Class(讓自訂型別支援索引)
Operator Class 告訴索引方法如何索引自訂型別:
-- 比較函式(B-tree 必需)
CREATE OR REPLACE FUNCTION money_amount_cmp(a money_amount, b money_amount)
RETURNS INTEGER AS $$
BEGIN
IF (a).currency != (b).currency THEN
RAISE EXCEPTION '無法比較不同幣別';
END IF;
IF (a).amount < (b).amount THEN RETURN -1;
ELSIF (a).amount > (b).amount THEN RETURN 1;
ELSE RETURN 0;
END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
-- 建立 B-tree Operator Class
CREATE OPERATOR CLASS money_amount_ops
DEFAULT FOR TYPE money_amount USING btree AS
OPERATOR 1 <, -- 策略 1:小於
OPERATOR 2 <=, -- 策略 2:小於或等於
OPERATOR 3 =, -- 策略 3:等於
OPERATOR 4 >=, -- 策略 4:大於或等於
OPERATOR 5 >, -- 策略 5:大於
FUNCTION 1 money_amount_cmp(money_amount, money_amount);
-- 現在可以建立 B-tree 索引
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
amount money_amount NOT NULL
);
CREATE INDEX idx_transactions_amount ON transactions (amount);
-- 索引支援排序與範圍查詢
SELECT * FROM transactions
WHERE amount > ROW(1000, 'TWD')::money_amount
ORDER BY amount;
Type Cast(型別轉換)
型別轉換定義兩個型別之間的轉換規則:
| 等級 | 關鍵字 | 觸發時機 |
|---|---|---|
| 隱式 | AS IMPLICIT | 自動觸發(函式呼叫、比較) |
| 賦值 | AS ASSIGNMENT | INSERT/UPDATE 賦值時觸發 |
| 明確 | (預設) | 需明確使用 CAST() 或 :: |
-- 建立隱式轉換
CREATE CAST (email_address AS TEXT)
WITH INOUT
AS IMPLICIT;
-- 建立賦值轉換
CREATE CAST (TEXT AS email_address)
WITH INOUT
AS ASSIGNMENT;
-- 查詢已定義的 Cast
SELECT castsource::regtype, casttarget::regtype, castcontext
FROM pg_cast
WHERE castsource = 'email_address'::regtype;
版本演進
| 版本 | 自訂型別相關改進 |
|---|---|
| PG 8.3 | GIN 索引強化 Operator Class 框架 |
| PG 9.2 | 引入原生 Range Type |
| PG 10 | Enum 型別 WAL 記錄改善 |
| PG 11 | ALTER TYPE ADD VALUE 可在交易中使用(部分限制) |
| PG 14 | 複合型別 ROW 過濾效能改進 |
| PG 16 | Operator Class 統計資訊收集改善 |
| PG 17 | Range 型別索引效能與平行查詢改進 |
常見陷阱
複合型別欄位存取需加括號
-- 錯誤:可能被解析為「表別名.欄位」
SELECT billing.city FROM customers;
-- 正確:加括號明確表示複合型別存取
SELECT (billing).city FROM customers;
Domain 的 CHECK 用 VALUE 不是欄位名
-- 錯誤:salary 未定義
CREATE DOMAIN bad_domain AS INT CHECK (salary > 0);
-- 正確:使用 VALUE 關鍵字
CREATE DOMAIN good_domain AS INT CHECK (VALUE > 0);
複合型別的 NULL 語義
-- ROW(NULL, 'TWD') 不等於 NULL
SELECT ROW(NULL, 'TWD')::money_amount IS NULL;
-- 結果: false(整個 ROW 不是 NULL)
SELECT (ROW(NULL, 'TWD')::money_amount).amount IS NULL;
-- 結果: true(子欄位是 NULL)
總結
自訂型別 是 PostgreSQL 型別可擴展性的核心:
- Composite Type 組合多欄位為結構體,適合地址、金額等複合概念
- Enum Type 定義有序固定集合,但無法刪除值、需重建遷移
- Range Type 原生表示區間,搭配排他約束完美解決時段重疊問題
- Domain 在既有型別上加約束,統一驗證邏輯,減少重複 CHECK
- Operator Class 讓自訂型別也能享有 B-tree、GiST 索引加速
- 設計原則:優先用 Domain 和 Enum 表達業務語義,Range 解決區間問題
下一篇,我們將深入探討 Views 與 Materialized Views——PostgreSQL 如何用視圖封裝複雜查詢並透過物化視圖加速分析。