Free

Laisser Claude modéliser des schémas multilingues Rails avec jsonb

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.


Les trois options sur la table

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.

Schéma : 3 colonnes jsonb + compromis sur les contraintes

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.

Couche modèle : envelopper le fallback

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.

Patrons de requête : ->> 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').

Articles avec une traduction spécifique

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.

Trier par titre dans une langue précise

Article.order("title->>'en' ASC")

Filtrer + trier par locale

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Requêtes que Claude écrit mal par défaut

# 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.

Index : quoi ajouter, quand

jsonb ne s'auto-indexe pas. Trois besoins courants → trois index :

1. Requêtes d'existence / @> contenance → index GIN

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.

2. Trier / égalité sur une locale spécifique → index d'expression

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.

3. Pas de requête fréquente → ne pas ajouter

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).

Les 4 directions où Claude se plante par défaut

Sur ce genre de design de schéma, le premier réflexe de Claude est souvent faux. Chope chacun :

1. Utilise jsonb comme sortie de secours « je ne connais pas le schéma »

« 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.

2. default: "{}" comme chaîne

Redirige : 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).

3. Chaque colonne jsonb reçoit null: false

Redirige : 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.

4. Filtre jsonb dans la couche applicative

« Je fais Article.all puis .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.

Checklist

Laisser Claude designer un schéma multilingue Rails avec jsonb — 6 règles :

  1. Écarte 19 tables / 19 colonnes d'abord. Sauf si la logique d'affichage diffère substantiellement selon la locale, jsonb est la seule réponse.
  2. 3 colonnes jsonb + contraintes étagées : title null: false, default: {} ; summary / content juste default: {}.
  3. default: {} est un littéral hash, pas une chaîne. La chaîne "{}" cause un mismatch de type.
  4. Le modèle fournit des accesseurs avec fallback : current → en → .values.first. Les vues ne voient jamais nil.
  5. Requêtes en SQL : title ? 'zh' (existence), title->>'zh' (valeur/tri). Ne tire pas en Ruby pour filtrer.
  6. Attends d'avoir un vrai besoin de requête avant d'indexer. Mettre GIN préventivement est de la sur-ingénierie ; petits volumes avec lookups slug/id n'ont besoin d'aucun index jsonb.

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.