Rails 多言語 schema:jsonb が 19 テーブル/カラムに勝つ、段階制約、->> クエリ、インデックスのタイミング。
how2claude は各記事を 19 言語(zh / en / ja / ko / ar / ...)で同時配信していて、データ層でどう格納するかは最初に決める必要がある。Claude にこのテーブルを設計させると、大抵 19 個の string カラム、もっとひどいと 19 テーブル——「言語ごとに articles テーブル 1 つ」を提案してくる。どちらも地雷。
正解は 1 テーブル + 3 つの jsonb カラム、各 jsonb 内部で locale を key にして値を保持する。本稿はなぜこの方式が勝つか、schema の書き方、クエリ方法、インデックスのタイミング、そして Claude がデフォルトで外す 4 方向を説明する。
候補をすべて並べる:
選択肢 A:19 テーブル(articles_zh, articles_en, ...)
- 1 記事取得に 19 回 join、または 19 回クエリ
- 20 言語目を追加 = CREATE TABLE
- 記事 1 件削除 → 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)
- 記事 1 件につき 1 行、locale は jsonb 内部の key
- 言語追加は schema を触らない(key を挿入するだけ)
- Postgres の一等市民——アトミッククエリ、インデックス、部分取得を完全サポート
- クエリ構文は若干うるさい(title->>'zh')が、ラップできる
C を選ぶ。以下は C を正しくやる方法。
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
制約が違う理由:
title:null: false + default: {}。タイトルは必須——記事には最低でも 1 言語のタイトルが必要。en だけでもいい、とにかく最低 1 つ。jsonb 全体が NULL なのは許さない。summary / content:default: {} のみ、NULL を許す。これらは空でいい(作成直後、コンテンツ未記入など)、制約は緩める。default: {} は必須。省くと、作成直後の Article.new で title が nil、直後の article.title["en"] で爆発:NoMethodError: undefined method '[]' for nil:NilClass。default を空 hash にしておけば全アクセスが安全。
Claude にこの migration を書かせるとしょっちゅう:
- default: {} を省く → 上記 nil 爆発
- null: false を全部に付ける → summary が NULL になれない、タイトルだけ先に作って後でコンテンツを埋める flow が詰まる
- default: "{}"(文字列リテラル)と書く → Postgres は文字列 "{}" を格納、空 json ではない、クエリ時に型がズレる
法則:jsonb カラムの default は {}(Ruby のハッシュリテラル)でなければならない、文字列ではない。null: false は必須の jsonb にのみ付ける。
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
3 段階 fallback:現在の locale → en → 何かある値。中国語ユーザーが日本語記事を見る時、日本語がなければ英語に落ち、英語もなければ「少なくともある値」に落ちる、絶対 nil にならない。
validates :title, presence: true は {} を自動で扱う——ActiveRecord は空 hash を blank 扱いするので、空 title はバリデーションを通らない。これが null: false + presence の組み合わせの利点。
->> vs ->Postgres jsonb の日常的な 2 演算子:
title->>'zh' → text を返す(文字列)title->'zh' → jsonb を返す(json 型を保持)99% ->> を使う——文字列を取り出して使う。-> は入れ子を続ける時のみ(例:content->'metadata'->>'author')。
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? は jsonb の「key 存在」演算子。title ハッシュに zh key がある 記事を返す。
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# 誤 1:jsonb 全体を文字列として比較
Article.where(title: '{"zh": "..."}')
# 誤 2:Ruby 層でフィルタ
Article.all.select { |a| a.title["zh"].present? } # 全テーブルをメモリに引く
# 誤 3:演算子なしで = 比較
Article.where("title->>'zh' = ?", "あるタイトル") # これは正しい、でも Claude は誤 1 と混ぜる
法則:フィルタ / ソート / 存在チェックは全て SQL 層で ->> か ? 演算子を使う。値をユーザーに見せるためだけに Ruby 層の article.title["zh"] を使う。
jsonb は自動インデックスされない。3 つの一般的需要に 3 種類のインデックスが対応:
add_index :articles, :title, using: :gin
title ? 'zh'、title @> '{"zh": ...}' 等を高速化。GIN は書き込みが遅く容量を食うが、jsonb の「key/value 存在性」クエリには必須。
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
title->>'en' = ? や ORDER BY title->>'en' のみ高速化する。英語タイトルでのみソートする(英語サイトのアルファベット順リストなど)なら、英語の expression index 1 つで十分。
jsonb インデックスは高コストのオブジェクト。記事総数が小さく(数千以内)、クエリが全て id/slug 経由なら、jsonb にプリインデックスしない。明確なクエリ需要が見えるまで待つ。
how2claude は現在 title/summary/content に jsonb インデックスを付けていない——総数がまだ小さく、クエリが全て slug 取得、インデックスは純粋な容量の浪費。ある程度(例えば 1 万記事 + バルククエリ頻出)に成長したら追加する。
この種の schema 設計を任せると、Claude の最初の反応はしばしば間違う。都度 intercept:
「このフィールドに何が入るか不明だから jsonb にしておこう...」
修正:jsonb が合うのは構造は安定だが key 集合が開放的か疎で広いケース——多言語、feature flags、ユーザー設定。「テーブル設計が面倒」の言い訳ではない。明確な key 集合がある(例:固定 5 フィールド)なら、普通のカラムを使う。
default: "{}" を文字列で書く修正:Rails がこれを文字列リテラル "{}" として Postgres に格納する、空 json ではない。マイグレーション後に article.title["zh"] で爆発(文字列はそのように subscript できない)。必ず default: {}(Ruby ハッシュリテラル)。
null: false修正:まずどれが必須かを問う。タイトルは必須(タイトル無しの記事は成り立たない)→ null: false。summary と content は空でいい(草稿期)→ default: {} のみ、null: false は付けない。制約を段階付けする、一律ではなく。
「
Article.allしてから Ruby で.select { |a| a.title["zh"] }...」
修正:jsonb の核心は SQL 層でのネイティブサポート。フィルタ / 存在判定 / ソートは全て Postgres にやらせる、->> / ? / @> を使う。Ruby に引き戻したら、テーブルが 1000 行超えた瞬間にパフォーマンスが死ぬ。
Claude に jsonb で Rails の多言語 schema を設計させる 6 条:
null: false, default: {};summary / content は default: {} のみ。default: {} はハッシュリテラルで、文字列ではない。文字列 "{}" は型ミスマッチを起こす。current → en → .values.first。ビュー層は絶対 nil を見ない。title ? 'zh'(存在)、title->>'zh'(値取得/ソート)。Ruby に引き戻してフィルタしない。本当の判断は「jsonb を使うか否か」ではない——19 言語の文脈では自明。本当の判断はどのフィールドが必須かオプショナルか、model 層の fallback チェーンはどう組むか、いつ「まだインデックス不要」から「expression index を追加」に切り替えるか。これらはプロダクトとデータ量の判断、Claude はコードをくれるがこれらの判断は代行しない。