Free

Дать Claude спроектировать многоязычную схему Rails через jsonb

Многоязычная схема 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 правильно.

Схема: 3 jsonb-колонки + компромиссы по ограничениям

Реальная таблица 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

Только схемы недостаточно — модели нужны аксессоры с 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")

Фильтр + сортировка по 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 не индексируется автоматически. Три частых потребности → три индекса:

1. Запросы существования / @>-включения → GIN-индекс

add_index :articles, :title, using: :gin

Ускоряет title ? 'zh', title @> '{"zh": ...}' и т.п. GIN тяжёл на запись и прожорлив по месту, но обязателен для запросов существования ключа/значения в jsonb.

2. Сортировка / равенство на конкретной locale → expression-индекс

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

Ускоряет только title->>'en' = ? или ORDER BY title->>'en'. Если сортируешь только по английскому заголовку (например, алфавитный список на английском сайте), одного английского expression-индекса достаточно.

3. Нет высокочастотных запросов → не добавляй

jsonb-индексы — дорогие объекты. Если объём статей маленький (тысячи) и все запросы по id/slug, не прединдексируй jsonb. Жди, пока появится чёткая потребность в запросе.

how2claude сейчас не имеет jsonb-индекса на title/summary/content — объём низкий, запросы по slug, индекс был бы чистой тратой места. Добавится при росте до определённой точки (скажем, 10k статей + частые bulk-запросы).

4 направления, куда Claude по умолчанию уходит не туда

При таком схемном проектировании первая реакция Claude часто ошибочна. Лови каждое:

1. Использует jsonb как «не знаю схему» запасной выход

«Не уверен, что тут будет храниться, сделаю jsonb...»

Перенаправь: jsonb подходит для ситуаций со стабильной структурой, но открытым или разреженно-широким набором ключей — многоязычие, feature flags, пользовательские настройки. Не оправдание, чтобы пропускать схемное проектирование. Если у тебя есть чёткий набор ключей (скажем, 5 фиксированных полей), используй обычные колонки.

2. default: "{}" как строка

Перенаправь: Rails сохранит это как строковый литерал "{}" в Postgres, не как пустой json. После миграции article.title["zh"] падает (строки так не индексируются). Должно быть default: {} (Ruby-хэш-литерал).

3. Каждая jsonb-колонка получает null: false

Перенаправь: Сначала спроси, какая на самом деле обязательна. Title обязателен (статья без заголовка — бессмыслица) → null: false. summary и content могут быть пустыми (черновиковая фаза) → только default: {}, без null: false. Градуируй ограничения, не бомби по площадям.

4. Фильтровать jsonb на уровне приложения

«Я делаю Article.all, а потом .select { |a| a.title["zh"] }...»

Перенаправь: Вся соль jsonb — в нативной поддержке в SQL. Вся фильтрация / существование / сортировка живут в Postgres через ->> / ? / @>. Вытащи в Ruby, и в момент, когда таблица дойдёт до тысячи строк, ты мёртв.

Чек-лист

Дать Claude спроектировать многоязычную Rails-схему через jsonb — 6 правил:

  1. Сначала исключи 19 таблиц / 19 колонок. Если только логика отображения не различается существенно по locale, jsonb — единственный ответ.
  2. 3 jsonb-колонки + многоуровневые ограничения: title null: false, default: {}; summary / content только default: {}.
  3. default: {} — хэш-литерал, не строка. Строка "{}" вызывает тип-несоответствие.
  4. Модель предоставляет аксессоры с fallback: current → en → .values.first. View никогда не видит nil.
  5. Запросы в SQL: title ? 'zh' (существование), title->>'zh' (значение/сортировка). Не тяни в Ruby для фильтрации.
  6. Жди реальной потребности в запросе, прежде чем индексировать. Превентивный GIN — это over-engineering; малые объёмы с slug/id lookups не нуждаются ни в каком jsonb-индексе.

Реальное решение — не «jsonb или нет» — с 19 языками ответ очевиден. Реальные решения: какие поля обязательны vs опциональны, как работает fallback-цепочка модели, когда переключаться с «ещё нет индекса» на «добавить expression-индекс». Это продуктовые и объёмные решения. Claude даёт тебе код, но эти решения за тебя не принимает.