Free

Lasciare che Claude modelli schemi multilingua Rails con jsonb

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.


Le tre opzioni sul tavolo

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.

Schema: 3 colonne jsonb + compromesso sui vincoli

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.

Livello model: avvolgere il fallback

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.

Pattern di query: ->> 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').

Articoli con una traduzione specifica

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.

Ordinare per titolo in lingua specifica

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

Filtrare + ordinare per locale

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

Query che Claude scrive male di default

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

Indici: cosa aggiungere, quando

jsonb non si indicizza da solo. Tre esigenze comuni mappano a tre indici:

1. Query di esistenza / @> contenimento → indice GIN

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.

2. Ordinamento / uguaglianza su locale specifico → indice espressione

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.

3. Nessuna query frequente → non aggiungere

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

Le 4 direzioni in cui Claude sbaglia di default

Quando fai questo tipo di design di schema, il primo istinto di Claude è spesso sbagliato. Prendi ciascuno al volo:

1. Usa jsonb come via di fuga "non conosco lo schema"

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

2. default: "{}" come stringa

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

3. Ogni colonna jsonb riceve null: false

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

4. Filtrare jsonb nel livello applicativo

"Faccio Article.all e 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.

Checklist

Lasciare che Claude disegni uno schema multilingua Rails con jsonb — 6 regole:

  1. Escludi 19 tabelle / 19 colonne prima. A meno che la logica di presentazione differisca sostanzialmente per locale, jsonb è l'unica risposta.
  2. 3 colonne jsonb + vincoli a livelli: title null: false, default: {}; summary / content solo default: {}.
  3. default: {} è un literal hash, non una stringa. La stringa "{}" causa mismatch di tipo.
  4. Il model fornisce accessor con fallback: current → en → .values.first. Le view non vedono mai nil.
  5. Query in SQL: title ? 'zh' (esistenza), title->>'zh' (valore/ordinamento). Non tirare indietro in Ruby per filtrare.
  6. Aspetta di avere un vero bisogno di query prima di indicizzare. Aggiungere GIN preventivamente è sovra-ingegnerizzazione; volumi bassi con lookup slug/id non necessitano di alcun indice jsonb.

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.