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