Многоязычная схема Rails: jsonb побеждает 19 таблиц/колонок, многоуровневые ограничения, ->>-запросы, когда индексировать.
how2claude публикует каждую статью на 19 языках (zh / en / ja / ko / ar / ...), и как это хранить на уровне данных — решать надо заранее. Попроси Claude спроектировать эту таблицу, и он скорее всего даст тебе 19 string-колонок, или хуже — 19 таблиц: «одна articles на язык». Оба варианта — ловушки.
Правильный ответ: 1 таблица + 3 jsonb-колонки, каждая jsonb — с locale в качестве ключа. Статья объясняет, почему этот подход выигрывает, как писать схему, как делать запросы, когда индексировать, и в каких 4 направлениях Claude по умолчанию уходит не туда.
Выложим всех кандидатов:
Вариант A: 19 таблиц (articles_zh, articles_en, ...)
- Вытащить одну статью требует 19 join / 19 запросов
- Добавление 20-го языка = CREATE TABLE
- Удаление статьи → каскад по 19 таблицам
- Плюс: single-table-запросы быстрые
- Реальность: если логика отображения на самом деле не различается по языкам (почти никогда), это избыточное партиционирование
Вариант B: 19 string-колонок (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Таблица становится огромной вширь (3 типа × 19 языков = 57 колонок)
- Большинство ячеек NULL (тайский обычно делается последним или никогда)
- Добавление 20-го языка = ALTER TABLE ADD COLUMN × 3
- Разреженный layout строки, плохая IO-эффективность
Вариант C: 3 jsonb-колонки (title jsonb, summary jsonb, content jsonb)
- Одна строка на статью, locale — ключ внутри jsonb
- Добавление языка не трогает схему (просто вставить ключ)
- Гражданин первого класса в Postgres — атомарные запросы, индексы, частичные fetches
- Синтаксис запросов слегка шумнее (title->>'zh'), но обёртывается
Бери C. Остальная часть статьи — как делать C правильно.
Реальная таблица articles у how2claude (выжимка):
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: {}. Заголовок обязателен — у статьи должен быть заголовок хотя бы на одном языке. Даже если только en, хоть он. Весь jsonb не может быть NULL.summary / content: только default: {}, NULL разрешён. Они могут быть пустыми (сразу после создания, до написания контента), так что ослабляем.default: {} обязателен. Пропусти — и только что созданный Article.new имеет title = nil, и сразу же article.title["en"] падает: NoMethodError: undefined method '[]' for nil:NilClass. Дефолт в пустой хэш делает все обращения безопасными.
Когда Claude пишет эту миграцию, он часто:
- Забывает default: {} → описанный выше nil-крах
- Добавляет null: false ко всем jsonb → summary не может быть NULL у только что созданных записей, которые ещё заполняются, ломает флоу «сначала заголовок, потом контент»
- Пишет default: "{}" (строковый литерал) → Postgres хранит литерал строки "{}" вместо пустого json, тип-несоответствие при запросе
Правило: дефолт jsonb-колонки должен быть {} — Ruby-хэш-литерал, а не строка. null: false только на обязательном jsonb.
Только схемы недостаточно — модели нужны аксессоры с 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 считает пустой хэш blank, так что пустой title не проходит валидацию. Это выгода от комбинации null: false + presence.
->> vs ->Два ежедневных оператора Postgres jsonb:
title->>'zh' → возвращает text (строку)title->'zh' → возвращает jsonb (сохраняет тип json)99% времени тебе нужен ->> — вытащить строку и использовать. Используй -> только когда продолжаешь вложенность (например content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? — оператор «ключ существует» в jsonb. Возвращает статьи, в чьём хэше title есть ключ zh.
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 не индексируется автоматически. Три частых потребности → три индекса:
add_index :articles, :title, using: :gin
Ускоряет title ? 'zh', title @> '{"zh": ...}' и т.п. GIN тяжёл на запись и прожорлив по месту, но обязателен для запросов существования ключа/значения в jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Ускоряет только title->>'en' = ? или ORDER BY title->>'en'. Если сортируешь только по английскому заголовку (например, алфавитный список на английском сайте), одного английского expression-индекса достаточно.
jsonb-индексы — дорогие объекты. Если объём статей маленький (тысячи) и все запросы по id/slug, не прединдексируй jsonb. Жди, пока появится чёткая потребность в запросе.
how2claude сейчас не имеет jsonb-индекса на title/summary/content — объём низкий, запросы по slug, индекс был бы чистой тратой места. Добавится при росте до определённой точки (скажем, 10k статей + частые bulk-запросы).
При таком схемном проектировании первая реакция Claude часто ошибочна. Лови каждое:
«Не уверен, что тут будет храниться, сделаю jsonb...»
Перенаправь: jsonb подходит для ситуаций со стабильной структурой, но открытым или разреженно-широким набором ключей — многоязычие, feature flags, пользовательские настройки. Не оправдание, чтобы пропускать схемное проектирование. Если у тебя есть чёткий набор ключей (скажем, 5 фиксированных полей), используй обычные колонки.
default: "{}" как строкаПеренаправь: Rails сохранит это как строковый литерал "{}" в Postgres, не как пустой json. После миграции article.title["zh"] падает (строки так не индексируются). Должно быть default: {} (Ruby-хэш-литерал).
null: falseПеренаправь: Сначала спроси, какая на самом деле обязательна. Title обязателен (статья без заголовка — бессмыслица) → null: false. summary и content могут быть пустыми (черновиковая фаза) → только default: {}, без null: false. Градуируй ограничения, не бомби по площадям.
«Я делаю
Article.all, а потом.select { |a| a.title["zh"] }...»
Перенаправь: Вся соль jsonb — в нативной поддержке в SQL. Вся фильтрация / существование / сортировка живут в Postgres через ->> / ? / @>. Вытащи в Ruby, и в момент, когда таблица дойдёт до тысячи строк, ты мёртв.
Дать Claude спроектировать многоязычную Rails-схему через jsonb — 6 правил:
null: false, default: {}; summary / content только default: {}.default: {} — хэш-литерал, не строка. Строка "{}" вызывает тип-несоответствие.current → en → .values.first. View никогда не видит nil.title ? 'zh' (существование), title->>'zh' (значение/сортировка). Не тяни в Ruby для фильтрации.Реальное решение — не «jsonb или нет» — с 19 языками ответ очевиден. Реальные решения: какие поля обязательны vs опциональны, как работает fallback-цепочка модели, когда переключаться с «ещё нет индекса» на «добавить expression-индекс». Это продуктовые и объёмные решения. Claude даёт тебе код, но эти решения за тебя не принимает.