Free

Deixando o Claude modelar schemas multilíngues no Rails com jsonb

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.


As três opções sobre a mesa

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.

Schema: 3 colunas jsonb + trade-off de constraints

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.

Camada de model: envolver o fallback

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.

Padrões de query: ->> 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').

Artigos com tradução específica

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.

Ordenar por título em idioma específico

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

Filtrar + ordenar por locale

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

Queries que Claude escreve errado por default

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

Índices: o que e quando adicionar

jsonb não auto-indexa. Três necessidades comuns mapeiam pra três índices:

1. Queries de existência / @> contêiner → índice GIN

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.

2. Ordenação / igualdade em locale específico → índice de expressão

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.

3. Sem query frequente → não adiciona

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

As 4 direções que Claude erra por default

Fazendo esse tipo de design de schema, o primeiro instinto do Claude muitas vezes é errado. Intercepta cada um:

1. Usa jsonb como válvula de escape "não sei o schema"

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

2. default: "{}" como string

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

3. Toda coluna jsonb com null: false

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

4. Filtra jsonb na camada de aplicação

"Faço Article.all e .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.

Checklist

Deixando Claude desenhar schema multilíngue Rails com jsonb — 6 regras:

  1. Descarta 19 tabelas / 19 colunas primeiro. A menos que a lógica de apresentação difira substancialmente por locale, jsonb é a única resposta.
  2. 3 colunas jsonb + constraints em camadas: title null: false, default: {}; summary / content só default: {}.
  3. default: {} é literal de hash, não string. String "{}" causa mismatch de tipo.
  4. Model com accessors com fallback: current → en → .values.first. Views nunca veem nil.
  5. Queries no SQL: title ? 'zh' (existência), title->>'zh' (valor/ordenação). Não puxa pro Ruby pra filtrar.
  6. Espera ter necessidade real pra indexar. Adicionar GIN preventivamente é sobre-engenharia; volumes baixos com lookup slug/id não precisam de índice jsonb nenhum.

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