Schema multilíngue Rails: jsonb vence 19 tabelas/colunas, constraints em camadas, queries ->>, quando indexar.
how2claude publica cada artigo em 19 idiomas (zh / en / ja / ko / ar / ...), e como guardar isso na camada de dados precisa ser decidido logo. Peça pro Claude desenhar a tabela e provavelmente ele te dá 19 colunas string, ou pior, 19 tabelas — "uma tabela articles por idioma". Os dois são armadilhas.
A resposta certa é 1 tabela + 3 colunas jsonb, cada jsonb chaveado por locale. Esse artigo cobre por que essa abordagem ganha, como escrever o schema, como consultar, quando indexar, e as 4 direções pras quais Claude vai por default se enganar.
Joga todas as candidatas na mesa:
Opção A: 19 tabelas (articles_zh, articles_en, ...)
- Buscar um artigo pede 19 joins / 19 queries
- Adicionar um 20º idioma = CREATE TABLE
- Apagar um artigo cascateia 19 tabelas
- Prós: queries de tabela única rápidas
- Realidade: a menos que a lógica de apresentação seja mesmo diferente por idioma (quase nunca), é sobre-partição
Opção B: 19 colunas string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- A tabela fica enorme (3 tipos × 19 idiomas = 57 colunas)
- A maioria das células é NULL (a tradução pra tailandês costuma chegar por último ou nunca)
- Adicionar um 20º idioma = ALTER TABLE ADD COLUMN × 3
- Layout de linha esparso, IO ruim
Opção C: 3 colunas jsonb (title jsonb, summary jsonb, content jsonb)
- Uma linha por artigo, locale é uma chave dentro do jsonb
- Adicionar idioma não toca o schema (só insere uma chave)
- Cidadão de primeira classe no Postgres — queries atômicas, índices, fetches parciais
- A sintaxe de query é um pouco mais ruidosa (title->>'zh') mas dá pra envolver
Vai de C. O resto desse artigo é sobre fazer C do jeito certo.
A tabela articles real do how2claude (trecho):
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
# ... outros bigint, timestamp etc.
end
Por que os constraints diferem:
title: null: false + default: {}. Título é obrigatório — um artigo precisa ter pelo menos uma tradução de título. Mesmo que só en, ao menos essa. O jsonb inteiro não pode ser NULL.summary / content: só default: {}, NULL permitido. Esses podem ficar vazios (ex. logo depois da criação, antes do conteúdo ser escrito), então afrouxa.default: {} é obrigatório. Sem ele, um Article.new recém-criado tem title = nil, e aí article.title["en"] explode: NoMethodError: undefined method '[]' for nil:NilClass. Default em hash vazio deixa todo acesso seguro.
Quando Claude escreve essa migration, ele costuma:
- Pular default: {} → o crash nil acima
- Adicionar null: false em toda jsonb → summary não pode ser NULL em registros recém-criados ainda sendo preenchidos, quebra o flow "título primeiro, conteúdo depois"
- Escrever default: "{}" (string literal) → Postgres guarda a string literal "{}" em vez de um json vazio, mismatch de tipo na hora de consultar
Regra: o default de uma coluna jsonb tem que ser {} literal de hash Ruby, não string. null: false só na jsonb obrigatória.
Só schema não basta — o model precisa de accessors com 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 em três passos: locale atual → en → qualquer coisa que exista. Um leitor chinês que cai num artigo em japonês, se não tem japonês cai pro inglês; se inglês também não, cai pra "alguma coisa que exista", nunca nil.
validates :title, presence: true lida com {} automaticamente — ActiveRecord trata hash vazio como blank, então title vazio não passa validação. Essa é a vantagem do combo null: false + presence.
->> vs ->Dois operadores jsonb do Postgres de uso diário:
title->>'zh' → retorna text (string)title->'zh' → retorna jsonb (preserva o tipo json)99% do tempo você quer ->> — puxa uma string e usa. Usa -> só quando vai aninhar mais (ex. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? é o operador "chave existe" do jsonb. Retorna artigos onde o hash title tem a chave zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Errado 1: comparar jsonb inteiro como string
Article.where(title: '{"zh": "..."}')
# Errado 2: filtrar em Ruby
Article.all.select { |a| a.title["zh"].present? } # puxa a tabela toda pra memória
# Errado 3: usar = sem operador
Article.where("title->>'zh' = ?", "algum título") # isso tá correto, mas Claude mistura com Errado 1
Regra: qualquer filtro / ordenação / check de existência acontece no SQL com ->> ou ?; só puxa pro Ruby article.title["zh"] quando vai renderizar pro usuário.
jsonb não auto-indexa. Três necessidades comuns mapeiam pra três índices:
add_index :articles, :title, using: :gin
Acelera title ? 'zh', title @> '{"zh": ...}', etc. GIN é pesado em escrita e ocupa espaço, mas é obrigatório pra queries de existência chave/valor em jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Só acelera title->>'en' = ? ou ORDER BY title->>'en'. Se só ordena por título inglês (ex. listagem alfabética do site inglês), um índice de expressão inglês basta.
Índices jsonb são caros. Se o volume de artigos é pequeno (milhares) e todas queries são por id/slug, não pré-indexa a jsonb. Espera até ter necessidade clara de query.
how2claude atualmente não tem índice jsonb em title/summary/content — volume baixo, queries são por slug, índice seria só desperdício de espaço. Adicionar quando queries em lote aparecerem com frequência (digamos, 10k artigos).
Fazendo esse tipo de design de schema, o primeiro instinto do Claude muitas vezes é errado. Intercepta cada um:
"Não tô certo do que esse campo precisa guardar, vou deixar jsonb..."
Redireciona: jsonb serve pra situações com estrutura estável mas conjunto de chaves aberto ou esparso-amplo — multilíngue, feature flags, preferências de usuário. Não é desculpa pra pular design de schema. Se você já tem conjunto de chaves claro (digamos, 5 campos fixos), usa colunas normais.
default: "{}" como stringRedireciona: Rails vai guardar isso como string literal "{}" no Postgres, não como json vazio. Depois da migration, article.title["zh"] explode (strings não subscript assim). Tem que ser default: {} (literal de hash Ruby).
null: falseRedireciona: Pergunta primeiro qual é realmente obrigatória. Title é obrigatório (artigo sem título não faz sentido) → null: false. summary e content podem estar vazios (fase rascunho) → só default: {}, sem null: false. Escalona os constraints; não vai de tacada.
"Faço
Article.alle.select { |a| a.title["zh"] }..."
Redireciona: A graça toda do jsonb é suporte nativo no SQL. Todo filtro / existência / ordenação mora no Postgres via ->> / ? / @>. Arrasta pro Ruby e no momento que a tabela bater mil linhas, você morre.
Deixando Claude desenhar schema multilíngue Rails com jsonb — 6 regras:
null: false, default: {}; summary / content só default: {}.default: {} é literal de hash, não string. String "{}" causa mismatch de tipo.current → en → .values.first. Views nunca veem nil.title ? 'zh' (existência), title->>'zh' (valor/ordenação). Não puxa pro Ruby pra filtrar.A decisão real não é "jsonb ou não" — com 19 idiomas a resposta é óbvia. As decisões reais são quais campos são obrigatórios vs opcionais, como a cadeia de fallback do model funciona, quando migrar de "sem índice ainda" pra "adicionar índice de expressão". Essas são decisões de produto e volume. Claude te dá o código mas não toma essas decisões por você.