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 дає тобі код, але ці рішення за тебе не приймає.