Schema Rails multilingua: jsonb batte 19 tabelle/colonne, vincoli a livelli, query ->>, quando indicizzare.
how2claude pubblica ogni articolo in 19 lingue (zh / en / ja / ko / ar / ...), e come memorizzarlo nello strato dati va deciso subito. Chiedi a Claude di progettare la tabella e ti darà probabilmente 19 colonne string, o peggio, 19 tabelle — "una tabella articles per lingua". Entrambe trappole.
La risposta giusta è 1 tabella + 3 colonne jsonb, ciascun jsonb con locale come chiave. Questo articolo copre perché questo approccio vince, come scrivere lo schema, come interrogare, quando indicizzare, e le 4 direzioni in cui Claude sbaglia di default.
Metti tutte le candidate sul tavolo:
Opzione A: 19 tabelle (articles_zh, articles_en, ...)
- Prendere un articolo richiede 19 join / 19 query
- Aggiungere una 20ª lingua = CREATE TABLE
- Cancellare un articolo si ripercuote su 19 tabelle
- Pro: query single-table veloci
- Realtà: a meno che la logica di presentazione differisca davvero per lingua (quasi mai), è sovra-partizionamento
Opzione B: 19 colonne string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- La tabella diventa enorme (3 tipi di contenuto × 19 lingue = 57 colonne)
- La maggior parte delle celle è NULL (la traduzione tailandese di solito arriva per ultima o mai)
- Aggiungere una 20ª lingua = ALTER TABLE ADD COLUMN × 3
- Layout di riga sparso, IO inefficiente
Opzione C: 3 colonne jsonb (title jsonb, summary jsonb, content jsonb)
- Una riga per articolo, il locale è una chiave dentro il jsonb
- Aggiungere lingua non tocca lo schema (basta inserire una chiave)
- Cittadino di prima classe in Postgres — query atomiche, indici, fetch parziali
- Sintassi di query un po' più rumorosa (title->>'zh') ma si può avvolgere
Vai con C. Il resto di questo articolo parla di fare C bene.
La tabella articles reale di how2claude (estratto):
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
# ... altri bigint, timestamp ecc.
end
Perché i vincoli differiscono:
title: null: false + default: {}. Il titolo è obbligatorio — un articolo deve avere almeno un titolo in una lingua. Anche solo en, almeno quello. L'intero jsonb non può essere NULL.summary / content: solo default: {}, NULL consentito. Questi possono essere vuoti (es. appena dopo la creazione, prima di scrivere il contenuto), quindi allenta.default: {} è obbligatorio. Ometti e un Article.new appena creato ha title = nil, e subito dopo article.title["en"] esplode: NoMethodError: undefined method '[]' for nil:NilClass. Mettere il default a hash vuoto rende ogni accesso sicuro.
Quando Claude scrive questa migration, tende a:
- Saltare default: {} → crash nil di cui sopra
- Aggiungere null: false a ogni jsonb → summary non può essere NULL su record nuovi ancora in compilazione, rompe il flusso "titolo prima, contenuto dopo"
- Scrivere default: "{}" (literal stringa) → Postgres salva la stringa literal "{}" invece di un json vuoto, mismatch di tipo in query
Regola: il default di una colonna jsonb deve essere {} literal hash Ruby, non stringa. null: false solo sul jsonb obbligatorio.
Solo lo schema non basta — il model serve accessor con 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 a tre passi: locale attuale → en → qualunque cosa esista. Un lettore cinese su un articolo in giapponese, se il giapponese non c'è ricade sull'inglese; se anche l'inglese manca, ricade su "quel che c'è", mai nil.
validates :title, presence: true gestisce {} automaticamente — ActiveRecord tratta l'hash vuoto come blank, quindi un title vuoto non passa la validazione. Questo è il vantaggio della combo null: false + presence.
->> vs ->Due operatori jsonb Postgres di uso quotidiano:
title->>'zh' → restituisce text (stringa)title->'zh' → restituisce jsonb (preserva il tipo json)99% delle volte vuoi ->> — tiri fuori una stringa e la usi. Usa -> solo quando vuoi annidare oltre (es. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? è l'operatore "chiave esiste" di jsonb. Restituisce articoli il cui hash title ha la chiave zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Sbagliato 1: confronto dell'intero jsonb come stringa
Article.where(title: '{"zh": "..."}')
# Sbagliato 2: filtrare in Ruby
Article.all.select { |a| a.title["zh"].present? } # tira tutta la tabella in memoria
# Sbagliato 3: usare = senza l'operatore
Article.where("title->>'zh' = ?", "qualche titolo") # questo è corretto, ma Claude lo mescola con Sbagliato 1
Regola: qualsiasi filtro / ordinamento / check di esistenza avviene in SQL via ->> o ?; ricorri a article.title["zh"] in Ruby solo quando renderizzi per l'utente.
jsonb non si indicizza da solo. Tre esigenze comuni mappano a tre indici:
add_index :articles, :title, using: :gin
Accelera title ? 'zh', title @> '{"zh": ...}', ecc. GIN è pesante in scrittura e occupa spazio, ma obbligatorio per query di esistenza chiave/valore jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Accelera solo title->>'en' = ? o ORDER BY title->>'en'. Se ordini solo per titolo inglese (es. lista alfabetica del sito inglese), un indice espressione inglese basta.
Gli indici jsonb sono oggetti costosi. Se il volume di articoli è piccolo (migliaia) e tutte le query sono per id/slug, non pre-indicizzare il jsonb. Aspetta che emerga un'esigenza di query chiara.
how2claude attualmente non ha indice jsonb su title/summary/content — volume basso, query per slug, l'indice sarebbe puro spreco di spazio. Aggiungerlo quando le query in blocco appaiono frequentemente (diciamo 10k articoli).
Quando fai questo tipo di design di schema, il primo istinto di Claude è spesso sbagliato. Prendi ciascuno al volo:
"Non sono sicuro di cosa conterrà questo campo, lo metto jsonb..."
Ridirigi: jsonb si adatta a situazioni con struttura stabile ma set di chiavi aperto o rado-largo — multilingua, feature flag, preferenze utente. Non è una scusa per saltare il design dello schema. Se hai già un set di chiavi chiaro (diciamo 5 campi fissi), usa colonne normali.
default: "{}" come stringaRidirigi: Rails lo salverà come literal stringa "{}" in Postgres, non come json vuoto. Dopo la migration, article.title["zh"] esplode (le stringhe non si subscribono così). Deve essere default: {} (literal hash Ruby).
null: falseRidirigi: Chiedi prima quale è davvero obbligatoria. Title è obbligatorio (articolo senza titolo non ha senso) → null: false. summary e content possono essere vuoti (fase bozza) → solo default: {}, nessun null: false. Graduare i vincoli; non sparare a tappeto.
"Faccio
Article.alle poi.select { |a| a.title["zh"] }..."
Ridirigi: Il punto di jsonb è il supporto SQL nativo. Tutti i filtri / esistenza / ordinamento vivono in Postgres via ->> / ? / @>. Riportalo in Ruby e nel momento in cui la tabella tocca mille righe, sei morto.
Lasciare che Claude disegni uno schema multilingua Rails con jsonb — 6 regole:
null: false, default: {}; summary / content solo default: {}.default: {} è un literal hash, non una stringa. La stringa "{}" causa mismatch di tipo.current → en → .values.first. Le view non vedono mai nil.title ? 'zh' (esistenza), title->>'zh' (valore/ordinamento). Non tirare indietro in Ruby per filtrare.La decisione reale non è "jsonb o no" — con 19 lingue la risposta è ovvia. Le decisioni reali sono quali campi sono obbligatori vs opzionali, come funziona la catena di fallback del model, quando passare da "nessun indice ancora" a "aggiungere un indice espressione". Sono decisioni di prodotto e volume. Claude ti dà il codice ma non prende quelle decisioni al posto tuo.