Schéma multilingue Rails : jsonb bat 19 tables/colonnes, contraintes étagées, requêtes ->>, quand indexer.
how2claude publie chaque article en 19 langues (zh / en / ja / ko / ar / ...), et comment stocker ça dans la couche données se décide dès le départ. Demande à Claude de concevoir la table et il te filera probablement 19 colonnes string, ou pire, 19 tables — « une table articles par langue ». Les deux sont des pièges.
La bonne réponse est 1 table + 3 colonnes jsonb, chaque jsonb avec la locale en clé. Cet article couvre pourquoi cette approche gagne, comment écrire le schéma, comment interroger, quand indexer, et les 4 directions où Claude se plante par défaut.
Pose toutes les candidates :
Option A : 19 tables (articles_zh, articles_en, ...)
- Récupérer un article demande 19 joins / 19 requêtes
- Ajouter une 20e langue = CREATE TABLE
- Supprimer un article cascade sur 19 tables
- Pour : requêtes mono-table rapides
- Réalité : sauf si la logique d'affichage diffère vraiment par langue (quasi jamais), c'est du sur-partitionnement
Option B : 19 colonnes string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- La table devient énorme (3 types × 19 langues = 57 colonnes)
- La plupart des cellules sont NULL (la traduction thaï arrive en dernier ou jamais)
- Ajouter une 20e langue = ALTER TABLE ADD COLUMN × 3
- Layout de ligne clairsemé, IO inefficaces
Option C : 3 colonnes jsonb (title jsonb, summary jsonb, content jsonb)
- Une ligne par article, la locale est une clé dans le jsonb
- Ajouter une langue ne touche pas le schéma (juste insérer une clé)
- Citoyen de première classe dans Postgres — requêtes atomiques, index, fetches partiels
- Syntaxe de requête un peu plus bruyante (title->>'zh') mais tu peux l'envelopper
Vas sur C. Le reste de cet article, c'est faire C correctement.
La table articles réelle de how2claude (extrait) :
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
# ... autres bigint, timestamp, etc.
end
Pourquoi les contraintes diffèrent :
title : null: false + default: {}. Le titre est obligatoire — un article doit avoir au moins un titre dans une langue. Même si c'est juste en, au minimum ça. Le jsonb entier ne peut pas être NULL.summary / content : juste default: {}, NULL autorisé. Ceux-là peuvent être vides (ex. juste après la création, avant que le contenu soit écrit), donc on relâche.default: {} est obligatoire. Omis, un Article.new fraîchement créé a title = nil, et article.title["en"] explose : NoMethodError: undefined method '[]' for nil:NilClass. Défaut à hash vide rend tous les accès safe.
Quand Claude écrit cette migration, il a tendance à :
- Zapper default: {} → le crash nil ci-dessus
- Mettre null: false sur tous les jsonb → summary ne peut pas être NULL sur les nouveaux records encore en cours de remplissage, casse le flux « titre d'abord, contenu après »
- Écrire default: "{}" (littéral chaîne) → Postgres stocke la chaîne littérale "{}" au lieu d'un json vide, décalage de type à la requête
Règle : le défaut d'une colonne jsonb doit être {} le littéral hash Ruby, pas une chaîne. null: false seulement sur le jsonb obligatoire.
Le schéma seul ne suffit pas — le modèle a besoin d'accesseurs avec 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 en trois étapes : locale actuelle → en → ce qui existe. Un lecteur chinois qui tombe sur un article en japonais, si pas de japonais redescend à l'anglais ; si l'anglais manque aussi, redescend à « ce qui est là », jamais nil.
validates :title, presence: true gère {} automatiquement — ActiveRecord considère un hash vide comme blank, donc un title vide ne passe pas la validation. C'est le bénéfice du combo null: false + presence.
->> vs ->Deux opérateurs jsonb Postgres au quotidien :
title->>'zh' → renvoie du text (chaîne)title->'zh' → renvoie du jsonb (préserve le type json)99% du temps tu veux ->> — tu sors une chaîne et tu l'utilises. Utilise -> seulement pour imbriquer plus loin (ex. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? est l'opérateur « clé existe » de jsonb. Renvoie les articles dont le hash title a la clé zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Faux 1 : comparer le jsonb entier comme une chaîne
Article.where(title: '{"zh": "..."}')
# Faux 2 : filtrer en Ruby
Article.all.select { |a| a.title["zh"].present? } # tire toute la table en mémoire
# Faux 3 : utiliser = sans l'opérateur
Article.where("title->>'zh' = ?", "un titre") # celui-ci est correct, mais Claude le mélange avec Faux 1
Règle : tout filtre / tri / test d'existence se passe en SQL via ->> ou ? ; tu ne vas sur Ruby article.title["zh"] que quand tu rends pour l'utilisateur.
jsonb ne s'auto-indexe pas. Trois besoins courants → trois index :
add_index :articles, :title, using: :gin
Accélère title ? 'zh', title @> '{"zh": ...}', etc. GIN est lourd en écriture et prend de l'espace, mais obligatoire pour les requêtes d'existence clé/valeur jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
N'accélère que title->>'en' = ? ou ORDER BY title->>'en'. Si tu ne tries que par titre anglais (ex. liste alphabétique du site anglais), un seul index d'expression anglais suffit.
Les index jsonb sont chers. Si le volume d'articles est petit (milliers) et que toutes les requêtes sont par id/slug, ne pré-indexe pas le jsonb. Attends un besoin de requête clair.
how2claude n'a actuellement aucun index jsonb sur title/summary/content — volume faible, requêtes par slug, l'index ne serait que gaspillage d'espace. À ajouter quand les requêtes en masse apparaîtront fréquemment (disons, 10k articles).
Sur ce genre de design de schéma, le premier réflexe de Claude est souvent faux. Chope chacun :
« Je ne suis pas sûr de ce que ce champ va contenir, je vais le faire en jsonb... »
Redirige : jsonb convient aux situations avec structure stable mais ensemble de clés ouvert ou creux-large — multilingue, feature flags, préférences utilisateur. Ce n'est pas une excuse pour sauter le design de schéma. Si tu as déjà un ensemble de clés clair (disons 5 champs fixes), utilise des colonnes normales.
default: "{}" comme chaîneRedirige : Rails stockera ça comme la chaîne littérale "{}" en Postgres, pas comme un json vide. Après la migration, article.title["zh"] explose (les chaînes ne s'indexent pas comme ça). Ça doit être default: {} (littéral hash Ruby).
null: falseRedirige : Demande d'abord laquelle est réellement obligatoire. Title est obligatoire (un article sans titre n'a pas de sens) → null: false. summary et content peuvent être vides (phase brouillon) → seulement default: {}, pas null: false. Hiérarchise les contraintes ; ne tire pas à vue.
« Je fais
Article.allpuis.select { |a| a.title["zh"] }... »
Redirige : Tout l'intérêt de jsonb, c'est le support SQL natif. Tout filtre / existence / tri vit dans Postgres via ->> / ? / @>. Tire-le en Ruby et au moment où la table atteint mille lignes, tu meurs.
Laisser Claude designer un schéma multilingue Rails avec jsonb — 6 règles :
null: false, default: {} ; summary / content juste default: {}.default: {} est un littéral hash, pas une chaîne. La chaîne "{}" cause un mismatch de type.current → en → .values.first. Les vues ne voient jamais nil.title ? 'zh' (existence), title->>'zh' (valeur/tri). Ne tire pas en Ruby pour filtrer.La vraie décision n'est pas « jsonb ou pas » — avec 19 langues la réponse est évidente. Les vraies décisions sont quels champs sont obligatoires vs optionnels, comment la chaîne de fallback du modèle fonctionne, quand passer de « pas d'index encore » à « ajouter un index d'expression ». Ce sont des décisions produit et volume. Claude te donne le code mais ne prend pas ces décisions pour toi.