免費

讓 Claude 用 jsonb 給 Rails 做多語言 schema

Rails 多語言 schema:jsonb 贏 19 表/欄位、約束分級、->> 查詢、索引時機。


how2claude 每篇文章同時發 19 種語言(zh / en / ja / ko / ar / ...),資料層怎麼存這件事得先決定清楚。如果你讓 Claude 設計這張表,它大概率會給你寫 19 個 string 欄位,或者更離譜,建 19 張表——「每種語言一個 articles 表」。兩個都是坑。

正解是 1 張表 + 3 個 jsonb 欄位,每個 jsonb 裡以 locale 為 key 存對應語言的值。本文講為什麼這個方案贏、怎麼寫 schema、怎麼查、怎麼建索引、Claude 預設會走錯的 4 個方向。


三種方案擺檯面

先把候選方案都寫出來:

方案 A:19 張表articles_zh, articles_en, ...)
- 查單篇要 join 19 次 / 或做 19 次查詢
- 加第 20 種語言 = CREATE TABLE
- 刪一個 Article → 級聯 19 張表
- 優點:單表查詢快
- 現實:除非你每種語言的展示邏輯完全不同(幾乎沒有這種場景),否則這是過度分表

方案 B:19 個 string 欄位title_zh, title_en, ..., summary_zh, summary_en, ...)
- 表變巨寬(3 種內容 × 19 種語言 = 57 欄位)
- 大部分儲存格是 NULL(泰語版很可能最後寫、或者永遠為空)
- 加第 20 種語言 = ALTER TABLE ADD COLUMN × 3
- 行內欄位稀疏,IO 效率低

方案 C:3 個 jsonb 欄位title jsonb, summary jsonb, content jsonb
- 每行一條紀錄,locale 作為 jsonb 內部的 key
- 加語言不動 schema(直接插新 key)
- Postgres 一等公民,支援原子查詢、索引、部分抓取
- 查詢語法稍繞(title->>'zh'),但可以封裝

選 C。下面只講 C 怎麼做對。

Schema:3 個 jsonb 欄位 + 約束的取捨

how2claude 的實際 articles 表(節選):

create_table :articles do |t|
  t.jsonb :title,   default: {}, null: false
  t.jsonb :summary, default: {}
  t.jsonb :content, default: {}

  t.string :slug, null: false
  t.boolean :free, default: false, null: false
  # ... 其他 bigint、timestamp 等
end

約束選擇的理由

  • titlenull: false + default: {}。title 是必填——一篇文章至少要有一個語言的標題。即使只有 en,也要有;不允許整個 jsonb 為 NULL
  • summary / content:只 default: {},允許 NULL。這兩個可以為空(比如剛建立時還沒寫內容),所以鬆綁。

default: {} 是必須的。如果省略,剛 Article.new 的時候 titlenil,隨後 article.title["en"] 就炸 NoMethodError: undefined method '[]' for nil:NilClass。default 設為空 hash 讓所有存取都安全。

讓 Claude 寫這個 migration 時它經常:
- 省略 default: {} → 上面的 nil 炸
- 把 null: false 全加上 → summary 即使是空內容也不能為 NULL,新建一篇先有 title 再填內容的 flow 就卡住了
- 寫 default: "{}"(字串字面值)→ Postgres 會存成字串 {} 而不是空 json,查詢時型別對不上

規律:jsonb 欄位的 default 必須是 {} 這個 Ruby 雜湊字面值,不是字串。null: false 只給必填的那個 jsonb 加。

Model 層:封裝 locale fallback

光有 schema 不夠,model 要提供帶 fallback 的存取器:

class Article < ApplicationRecord
  validates :title, presence: true

  def title_for(locale = I18n.locale)
    title[locale.to_s] || title["en"] || title.values.first
  end

  def summary_for(locale = I18n.locale)
    summary[locale.to_s] || summary["en"]
  end

  def content_for(locale = I18n.locale)
    content[locale.to_s] || content["en"]
  end
end

三級 fallback:當前 locale → en → 隨便一個。中文使用者看日語文章時,日語沒有就掉回英文;英文也沒有就掉回「至少有的那種」,反正不 nil。

validates :title, presence: true 會自動處理 {}——ActiveRecord 把空 hash 視為 blank,所以空 title 過不了校驗。這正是 null: false + presence 組合的好處。

查詢 pattern:->> vs ->

Postgres jsonb 兩個常用運算符:

  • title->>'zh'返回 text(字串)
  • title->'zh'返回 jsonb(保持 json 型別)

幾乎所有時候你都要 ->>——拿字串出來用。-> 是當你要繼續巢狀查詢時才用(比如 content->'metadata'->>'author')。

有某個 locale 翻譯的文章

Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'

? 是 jsonb 的「key 存在」運算符。返回 title 這個 hash 裡zh 這個 key 的文章。

按特定語言的標題排序

Article.order("title->>'en' ASC")

按 locale 過濾 + 排序

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Claude 預設會寫錯的查詢

# 錯 1:整個 jsonb 當字串比
Article.where(title: '{"zh": "..."}')

# 錯 2:Ruby 層過濾
Article.all.select { |a| a.title["zh"].present? }  # 把全表拉到記憶體

# 錯 3:用 = 比較 jsonb 欄位
Article.where("title->>'zh' = ?", "某標題")  # 這個其實對,但 Claude 經常和錯 1 混

規律:凡是想「過濾 / 排序 / 存在」的,都在 SQL 層用 ->>? 運算符;只有取值給使用者看才在 Ruby 層 article.title["zh"]

索引:什麼時候加、加什麼

jsonb 不自動索引。三種常見需求分別對應三種索引:

1. 存在查詢 / @> 包含查詢 → GIN index

add_index :articles, :title, using: :gin

能加速 title ? 'zh'title @> '{"zh": ...}' 這類查詢。GIN 索引寫入慢、占空間,但對 jsonb 的「key/value 存在性」查詢必需。

2. 按特定 locale 排序 / 精確過濾 → expression index

add_index :articles, "(title->>'en')", name: "idx_articles_title_en"

只給 title->>'en' = ?ORDER BY title->>'en' 加速。如果你只按英文排序(比如英文站做字母序列表),加一個英文的 expression index 就夠。

3. 沒有高頻查詢 → 不加

jsonb 是昂貴的索引物件。如果文章總量不大(幾千篇以內)而且查詢都是按 id/slug,別給 jsonb 預加索引。讓它長大到有明確查詢需求再加。

how2claude 現在沒給 title/summary/content 加 jsonb 索引——總量還小、查詢都是按 slug 取單篇,加了純佔空間。等增長到某個點(比如 1 萬篇 + 批量查詢出現頻繁),再加。

Claude 預設走錯的 4 個方向

讓 Claude 做這類 schema 設計時它的第一反應經常錯。攔下來:

1. 把 jsonb 當「我不知道 schema 就這麼塞」的逃生口

「這個欄位不確定要存什麼,我給個 jsonb...」

攔下來:jsonb 適用的是結構穩定但 key 集合開放或稀疏寬的場景——多語言、feature flags、使用者偏好。不是「我懶得建表」的藉口。一旦你有清晰的 key 集合(比如固定 5 個欄位),用正常欄位。

2. default: "{}" 當字串

攔下來:Rails 會把這個存成 Postgres 字串 "{}",不是 empty json。遷移完跑 article.title["zh"] 會炸(字串不能這麼取)。必須寫 default: {}(Ruby 雜湊字面值)。

3. 所有 jsonb 欄位都 null: false

攔下來:先問哪個是必填。title 必填(文章沒標題不成立)→ null: false。summary 和 content 可以為空(草稿期)→ 只有 default:{},不加 null:false。約束分級,不是一刀切

4. 應用層做 jsonb 過濾

「我把所有 Article 取出來,在 Ruby 裡 .select { |a| a.title["zh"] }...」

攔下來:jsonb 的重點就是 SQL 層原生支援。凡是過濾、存在判斷、排序都讓 Postgres 做,用 ->> / ? / @>。拉回 Ruby 一旦表上千行效能就跪。

清單

讓 Claude 用 jsonb 給 Rails 做多語言 schema 的 6 條:

  1. 先排除 19 表 / 19 欄位方案。除非確認 locale 之間展示邏輯根本不同,否則 jsonb 是單選題。
  2. 3 個 jsonb 欄位 + 分級約束:title null: false, default: {};summary / content 只 default: {}
  3. default: {} 是雜湊字面值,不是字串。字串 "{}" 會導致型別錯配。
  4. Model 提供帶 fallback 的存取器current → en → .values.first。視圖層永遠不會拿到 nil。
  5. 查詢在 SQL 層title ? 'zh'(存在)、title->>'zh'(取值/排序),不要拉回 Ruby 過濾。
  6. 索引等有查詢需求再加。先上 GIN 是過早優化;總量小時按 slug/id 取單篇不需要任何 jsonb 索引。

真正的決定不是「用不用 jsonb」——在 19 種語言的場景下這個答案顯然。真正的決定是哪些欄位必填 vs 可空、model 層 fallback 鏈怎麼走、什麼時候從「先不加索引」切換到「加 expression index」。這些都是產品和資料量的判斷,Claude 會給你程式碼但不會替你做這些判斷。