自訂型別:ENUM、Domain、Range 與複合型別 | PostgreSQL

2026/06/28
自訂型別:ENUM、Domain、Range 與複合型別 | PostgreSQL

PostgreSQL 擁有業界最強大的型別可擴展性。透過 Composite TypeEnum TypeRange TypeDomain 四種自訂型別形式,加上自訂運算子與 Operator Class 索引支援,你可以從根本延伸資料庫的語義能力,讓資料模型精確對應業務領域。

型別系統的可擴展性

PostgreSQL 的型別系統建立在系統目錄之上,每個型別、運算子、索引類別都是資料庫物件,可以動態新增:

PostgreSQL 型別擴展架構

pg_type       — 型別定義(OID、輸入/輸出函式、儲存策略)
pg_operator   — 運算子定義(符號、左右運算元型別、底層函式)
pg_opclass    — Operator Class(索引方法如何使用運算子)
pg_cast       — 型別轉換規則(隱式/明確/賦值)
pg_enum       — Enum 型別的列舉值
pg_range      — Range 型別的範圍定義

四種自訂型別形式:

形式語法適用情境
Composite TypeCREATE TYPE ... AS (...)多欄位複合結構、函式回傳值
Enum TypeCREATE TYPE ... AS ENUM (...)固定有限集合的分類值
Range TypeCREATE TYPE ... AS RANGE (...)連續值的區間(時間段、數值範圍)
DomainCREATE 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 原生表示連續值的區間,支援包含/排除端點與無限端點:

內建範圍型別

型別子型別說明
int4rangeinteger整數範圍
int8rangebigint大整數範圍
numrangenumeric精確數值範圍
tsrangetimestamp時間戳記範圍
tstzrangetimestamptz帶時區時間戳記範圍
daterangedate日期範圍
-- 建立範圍([] 包含端點,() 排除端點)
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 ASSIGNMENTINSERT/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.3GIN 索引強化 Operator Class 框架
PG 9.2引入原生 Range Type
PG 10Enum 型別 WAL 記錄改善
PG 11ALTER TYPE ADD VALUE 可在交易中使用(部分限制)
PG 14複合型別 ROW 過濾效能改進
PG 16Operator Class 統計資訊收集改善
PG 17Range 型別索引效能與平行查詢改進

常見陷阱

複合型別欄位存取需加括號

-- 錯誤:可能被解析為「表別名.欄位」
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 如何用視圖封裝複雜查詢並透過物化視圖加速分析。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。