Free

Claude に jsonb で Rails の多言語 schema を設計させる

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 方向を説明する。


3 つの選択肢を並べる

候補をすべて並べる:

選択肢 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 を正しくやる方法。

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: {}。タイトルは必須——記事には最低でも 1 言語のタイトルが必要。en だけでもいい、とにかく最低 1 つ。jsonb 全体が NULL なのは許さない。
  • summary / contentdefault: {} のみ、NULL を許す。これらは空でいい(作成直後、コンテンツ未記入など)、制約は緩める。

default: {} は必須。省くと、作成直後の Article.newtitlenil、直後の 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 にのみ付ける

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

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')。

特定 locale の翻訳がある記事

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

? は jsonb の「key 存在」演算子。title ハッシュに 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:演算子なしで = 比較
Article.where("title->>'zh' = ?", "あるタイトル")  # これは正しい、でも Claude は誤 1 と混ぜる

法則:フィルタ / ソート / 存在チェックは全て SQL 層で ->>? 演算子を使う。値をユーザーに見せるためだけに Ruby 層の article.title["zh"] を使う

インデックス:いつ、何を追加するか

jsonb は自動インデックスされない。3 つの一般的需要に 3 種類のインデックスが対応:

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 1 つで十分。

3. 高頻度クエリなし → 追加しない

jsonb インデックスは高コストのオブジェクト。記事総数が小さく(数千以内)、クエリが全て id/slug 経由なら、jsonb にプリインデックスしない。明確なクエリ需要が見えるまで待つ。

how2claude は現在 title/summary/content に jsonb インデックスを付けていない——総数がまだ小さく、クエリが全て slug 取得、インデックスは純粋な容量の浪費。ある程度(例えば 1 万記事 + バルククエリ頻出)に成長したら追加する。

Claude がデフォルトで外す 4 方向

この種の schema 設計を任せると、Claude の最初の反応はしばしば間違う。都度 intercept:

1. jsonb を「schema わからないからこれでいいや」の逃げ道として使う

「このフィールドに何が入るか不明だから jsonb にしておこう...」

修正:jsonb が合うのは構造は安定だが key 集合が開放的か疎で広いケース——多言語、feature flags、ユーザー設定。「テーブル設計が面倒」の言い訳ではない。明確な key 集合がある(例:固定 5 フィールド)なら、普通のカラムを使う。

2. default: "{}" を文字列で書く

修正:Rails がこれを文字列リテラル "{}" として Postgres に格納する、空 json ではない。マイグレーション後に article.title["zh"] で爆発(文字列はそのように subscript できない)。必ず default: {}(Ruby ハッシュリテラル)。

3. jsonb カラム全てに null: false

修正:まずどれが必須かを問う。タイトルは必須(タイトル無しの記事は成り立たない)→ null: false。summary と content は空でいい(草稿期)→ default: {} のみ、null: false は付けない。制約を段階付けする、一律ではなく

4. jsonb のフィルタをアプリケーション層でやる

Article.all してから Ruby で .select { |a| a.title["zh"] } ...」

修正:jsonb の核心は SQL 層でのネイティブサポート。フィルタ / 存在判定 / ソートは全て Postgres にやらせる、->> / ? / @> を使う。Ruby に引き戻したら、テーブルが 1000 行超えた瞬間にパフォーマンスが死ぬ。

チェックリスト

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 言語の文脈では自明。本当の判断はどのフィールドが必須かオプショナルか、model 層の fallback チェーンはどう組むか、いつ「まだインデックス不要」から「expression index を追加」に切り替えるか。これらはプロダクトとデータ量の判断、Claude はコードをくれるがこれらの判断は代行しない。