🎯 什麼情境該想到我

當你「有繼承階層,想讓每個類別各自對應一張表、資料表保持正規化」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:讓繼承階層中每個類別(含父類別)各自對應一張表,用 join 把一個物件的父子欄位組合起來。

做法要點:

  • 父類別一張表放共通欄位,每個子類別一張表放其專屬欄位。
  • 各表以相同主鍵相連;載入某物件時 join 父表與對應子表取得完整欄位。
  • 資料表對應類別結構、少有空欄位,關係清楚、正規化程度高。
  • 三種繼承對應中最貼近物件模型的一種。

★★ 先講 Go:這個模式的前提在 Go 不成立

// ★ Go 沒有繼承,而嵌入沒有虛擬分派(見 [[工具-Go嵌入機制]])
type Base struct{}
func (b Base) Name() string  { return "base" }
func (b Base) Greet() string { return "hi, " + b.Name() }   // ★ b 永遠是 Base
type Child struct{ Base }
func (c Child) Name() string { return "child" }
Child{}.Greet()   // ★★ "hi, base"

★★ 所以「把繼承階層存進資料庫」這個問題,在 Go 裡換成
「把一組介面實作/判別聯集存進資料庫」。

★ 三種對應方式仍然適用,只是「子類」變成「實作型別」(見 以子類取代型別碼)。

★ Go 的落地:父表 + 子表,用同一個 ID

CREATE TABLE payments (                      -- ★ 父表:共同欄位
    id     text PRIMARY KEY,
    kind   text   NOT NULL,                  -- ★★ 判別欄位仍然需要
    amount bigint NOT NULL
);
CREATE TABLE card_payments (                 -- ★ 子表:專屬欄位
    payment_id text PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT,
    last4      text NOT NULL,                -- ★★ 可以 NOT NULL —— 這是本模式的核心優勢
    brand      text NOT NULL
);
CREATE TABLE transfer_payments (
    payment_id text PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT,
    bank_code  text NOT NULL,
    account_no text NOT NULL
);

★★ NOT NULL 是這個模式存在的全部理由。
單表繼承做不到;而「每個 card_payment 都必須有 last4」這件事
只有資料庫能真正強制。

★★ 讀取:三種寫法,成本差很多

-- ★ ① 已知型別 → 一次 JOIN
SELECT p.*, c.last4, c.brand
  FROM payments p JOIN card_payments c ON c.payment_id = p.id
 WHERE p.id = $1;
 
-- ★★ ② 未知型別 → 這是本模式最痛的地方
--    a. 先查 payments 拿 kind,再依 kind 查對應的子表 → ★ 2 次往返
--    b. ★ LEFT JOIN 所有子表 → 欄位變成 N 倍,而且大多是 NULL(★ 又回到單表的問題)
--    c. ★★ 在 SQL 裡用 CASE 組成 jsonb(見下)
 
-- ★ ③ 列出所有付款(跨子型別)
SELECT p.*,
       to_jsonb(c) - 'payment_id' AS card,
       to_jsonb(t) - 'payment_id' AS transfer
  FROM payments p
  LEFT JOIN card_payments     c ON c.payment_id = p.id
  LEFT JOIN transfer_payments t ON t.payment_id = p.id;
-- ★ 一次往返、欄位不爆;★★ 代價:子型別增加時這個查詢要改

★★ ②a(兩次往返)在「列一頁付款」時會變成 N+1:
100 筆付款、3 種型別 → 要嘛 1 + 3 次查詢(按型別分組批次查),要嘛 101 次。
★ 正確做法:按 kind 分組,每組一次 WHERE payment_id = ANY($1) ——
★★ 這比 LEFT JOIN 全部子表更省,也比 N+1 快得多。

★★ 寫入:必須是原子的,而且順序固定

func (r *Repo) Insert(ctx context.Context, q db.Querier, p Payment) error {
    // ★★ 父先、子後(外鍵方向決定)
    if err := q.InsertPayment(ctx, db.InsertPaymentParams{
        ID: p.ID(), Kind: p.Kind(), Amount: p.Amount(),
    }); err != nil { return fmt.Errorf("insert payment: %w", err) }
 
    switch v := p.(type) {
    case CardPayment:
        return q.InsertCardPayment(ctx, …)
    case TransferPayment:
        return q.InsertTransferPayment(ctx, …)
    }
    return fmt.Errorf("unknown payment type %T", p)   // ★★ 這一行會在交易內觸發 rollback
}

★★ 兩個必須處理的完整性問題:

① ★★ 父列存在但子列不存在(「孤兒父」)
   → 交易保證了原子性,但★ 若有人直接 INSERT payments 就會出現
   → ★ 資料庫層無法強制「每個 payment 必須有一個對應的子列」
     (★★ 這是類別表繼承的根本缺陷 —— 反向的外鍵約束不存在)
   → ★ 只能靠:① 寫入權只給應用程式 ② 定期一致性檢查

② ★ 刪除順序:子先、父後(或用 ON DELETE CASCADE,★ 但見 [[外鍵對應]] 的警告)

★★ 三種繼承對應的選擇(判準)

★★ 選類別表繼承的唯一充分理由:★ 你需要子型別專屬欄位的 NOT NULL 與外鍵約束。
★ 代價清單:
  ① ★★ 未知型別的讀取需要 N 次查詢或 LEFT JOIN 全部子表
  ② ★★ 「父必須有子」無法用外鍵強制
  ③ ★ 加一個子型別要新增一張表(★ 但不必 ALTER 既有表 —— 這是優點)
  ④ ★ 寫入要兩次 INSERT,刪除要兩次 DELETE

★ 若你發現自己一直在 LEFT JOIN 所有子表,那你付了類別表繼承的成本、
卻得到單表繼承的欄位形狀——★ 這時改用 單表繼承 + CHECK 更划算。

★ Go 的一個實用替代:父表 + jsonb 存專屬欄位

CREATE TABLE payments (
    id      text PRIMARY KEY,
    kind    text   NOT NULL,
    amount  bigint NOT NULL,
    details jsonb  NOT NULL,          -- ★ 專屬欄位放這裡
    CHECK (jsonb_typeof(details) = 'object')
);

★ 優點:一次查詢、加子型別不用 migration、Go 端用 json.RawMessage 延遲解析。
★★ 代價:失去 NOT NULL 與外鍵(見 序列化大物件)——
而那正是你原本選類別表繼承的理由。所以這是一個明確的取捨,不是升級。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 讀取單一物件需要多次 join,深階層時效能較差。

  • 表數量多,schema 較繁瑣。

  • 追求查詢速度、可容忍空欄位改用單表繼承;不想 join 改用具象表繼承。

  • ★★ 子型別欄位可以 NOT NULL 是這個模式存在的全部理由。 不需要它就不要付這個成本。

  • ★★ 「每個父列必須有一個子列」無法用外鍵強制(反向外鍵不存在)。只能靠寫入權限 + 定期一致性檢查。

  • ★★ 未知型別的讀取是最痛的地方:兩次往返,在列表頁會變成 N+1。正確做法是按 kind 分組、每組一次 ANY($1)。

  • ★ 若你一直在 LEFT JOIN 所有子表,就改用 單表繼承 + CHECK——你付了成本卻沒得到好處。

  • ★ 寫入順序父先子後、刪除順序子先父後。 用 ON DELETE CASCADE 要先讀 外鍵對應 的警告。

  • ★ 判別欄位(kind)仍然需要,否則你不知道該查哪張子表。

  • 加子型別要新增一張表,但不必 ALTER 既有表——這是它相對單表繼承的優點。

  • 「父表 + jsonb」是明確的取捨而非升級:換回一次查詢,但失去 NOT NULL 與外鍵。

🔗 相關工具