Schema multilingüe Rails: jsonb gana a 19 tablas/columnas, restricciones por niveles, consultas ->>, cuándo indexar.
how2claude publica cada artículo en 19 idiomas (zh / en / ja / ko / ar / ...), y cómo guardar eso en la capa de datos hay que decidirlo desde el inicio. Pídele a Claude que diseñe la tabla y lo más probable es que te dé 19 columnas string, o peor, 19 tablas — "una tabla articles por idioma". Las dos son trampas.
La respuesta correcta es 1 tabla + 3 columnas jsonb, cada jsonb con locale como clave. Este artículo cubre por qué este enfoque gana, cómo escribir el schema, cómo consultar, cuándo indexar y las 4 direcciones en las que Claude se equivoca por defecto.
Pon todas las opciones candidatas encima:
Opción A: 19 tablas (articles_zh, articles_en, ...)
- Traer un artículo pide 19 joins / 19 consultas
- Añadir un idioma 20 = CREATE TABLE
- Borrar un artículo cascadea en 19 tablas
- Pro: consultas de tabla única rápidas
- Realidad: salvo que la lógica de presentación sea realmente distinta por idioma (casi nunca), es sobre-partición
Opción B: 19 columnas string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- La tabla se vuelve enorme (3 tipos × 19 idiomas = 57 columnas)
- Casi todas las celdas son NULL (la traducción al tailandés suele llegar última o nunca)
- Añadir un idioma 20 = ALTER TABLE ADD COLUMN × 3
- Fila con campos dispersos, mala eficiencia de IO
Opción C: 3 columnas jsonb (title jsonb, summary jsonb, content jsonb)
- Una fila por artículo, el locale es una clave dentro del jsonb
- Añadir idioma no toca el schema (solo insertar una clave)
- Ciudadano de primera clase en Postgres — consultas atómicas, índices, fetches parciales
- La sintaxis de consulta es algo más ruidosa (title->>'zh') pero puedes envolverla
Vas con C. El resto de este artículo va de hacer C bien.
La tabla articles real de how2claude (extracto):
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
# ... otros bigint, timestamp, etc.
end
Por qué las restricciones difieren:
title: null: false + default: {}. El título es obligatorio — un artículo debe tener al menos un idioma con título. Aunque solo sea en, mínimo ese. El jsonb entero no puede ser NULL.summary / content: solo default: {}, NULL permitido. Estos pueden estar vacíos (ej. justo tras crear, antes de escribir el contenido), así que relaja.default: {} es obligatorio. Si lo omites, un Article.new recién creado tiene title = nil, y entonces article.title["en"] revienta: NoMethodError: undefined method '[]' for nil:NilClass. Poner por defecto un hash vacío hace todos los accesos seguros.
Cuando Claude escribe esta migration, suele:
- Saltarse default: {} → el crash nil de arriba
- Poner null: false en cada jsonb → summary no puede ser NULL en registros recién creados que aún se están rellenando, rompe el flujo "título primero, contenido después"
- Escribir default: "{}" (literal string) → Postgres guarda la cadena literal "{}" en lugar de un json vacío, desajuste de tipo al consultar
Regla: el default de una columna jsonb debe ser {} el literal de hash Ruby, no una cadena. null: false solo en el jsonb obligatorio.
Con el schema solo no basta — el modelo necesita accesores 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 en tres pasos: locale actual → en → lo que exista. Un lector chino que entra a un artículo en japonés cae al inglés; si tampoco está, cae a "lo que haya", nunca nil.
validates :title, presence: true maneja {} automáticamente — ActiveRecord trata el hash vacío como blank, así que un title vacío no pasa la validación. Ese es el payoff del combo null: false + presence.
->> vs ->Dos operadores jsonb de Postgres que usas a diario:
title->>'zh' → devuelve text (cadena)title->'zh' → devuelve jsonb (preserva el tipo json)El 99% del tiempo quieres ->> — sacas una cadena y la usas. Usa -> solo cuando vayas a anidar más (ej. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? es el operador "existe la clave" de jsonb. Devuelve artículos donde el hash title tiene la clave zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Mal 1: comparar jsonb entero como cadena
Article.where(title: '{"zh": "..."}')
# Mal 2: filtrar en Ruby
Article.all.select { |a| a.title["zh"].present? } # trae la tabla entera a memoria
# Mal 3: usar = sin el operador
Article.where("title->>'zh' = ?", "algún título") # esto es correcto, pero Claude lo mezcla con Mal 1
Regla: cualquier filtro / orden / comprobación de existencia va en SQL con ->> o ?; solo vas a Ruby article.title["zh"] cuando renderizas para el usuario.
jsonb no se indexa solo. Tres necesidades típicas se mapean a tres índices:
add_index :articles, :title, using: :gin
Acelera title ? 'zh', title @> '{"zh": ...}', etc. GIN es pesado en escritura y ocupa sitio, pero es obligatorio para consultas de existencia clave/valor en jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Solo acelera title->>'en' = ? o ORDER BY title->>'en'. Si solo ordenas por título en inglés (ej. listado alfabético del sitio inglés), un índice de expresión en inglés basta.
Los índices jsonb son caros. Si el número de artículos es pequeño (miles) y todas las consultas son por id/slug, no preindexes el jsonb. Espera a que haya una necesidad clara.
how2claude no tiene índice jsonb en title/summary/content ahora mismo — el volumen es bajo, las consultas son por slug, el índice solo ocuparía sitio. Se añadirá cuando las consultas en lote aparezcan con frecuencia (digamos, 10k artículos).
Al diseñar este tipo de schema, el primer instinto de Claude suele fallar. Atrapa cada uno:
"No estoy seguro de qué tiene que guardar este campo, lo pondré jsonb..."
Redirige: jsonb encaja cuando la estructura es estable pero el conjunto de claves es abierto o ralo-ancho — multilingüe, feature flags, preferencias de usuario. No es una excusa para saltarse el diseño de schema. Si ya tienes un conjunto de claves claro (digamos, 5 campos fijos), usa columnas normales.
default: "{}" como cadenaRedirige: Rails guardará la cadena literal "{}" en Postgres, no un json vacío. Tras la migration, article.title["zh"] revienta (las cadenas no se subscripten así). Tiene que ser default: {} (literal de hash Ruby).
null: falseRedirige: Pregunta primero cuál es de verdad obligatoria. Title es obligatorio (un artículo sin título no tiene sentido) → null: false. summary y content pueden estar vacíos (fase borrador) → solo default: {}, sin null: false. Escalona las restricciones, no las pongas en bloque.
"Hago
Article.ally luego.select { |a| a.title["zh"] }..."
Redirige: La gracia de jsonb es el soporte nativo en SQL. Todo filtro / existencia / orden vive en Postgres vía ->> / ? / @>. Lo arrastras a Ruby y, en cuanto la tabla llega a mil filas, te mueres.
Que Claude diseñe schemas multilingües Rails con jsonb — 6 reglas:
null: false, default: {}; summary / content solo default: {}.default: {} es un literal de hash, no una cadena. La cadena "{}" causa desajuste de tipo.current → en → .values.first. Las vistas nunca ven nil.title ? 'zh' (existencia), title->>'zh' (valor/orden). No los traigas a Ruby para filtrar.La decisión real no es "jsonb o no" — con 19 idiomas la respuesta es obvia. Las decisiones reales son qué campos son obligatorios vs opcionales, cómo se encadena el fallback en el modelo, cuándo pasar de "sin índice aún" a "añadir un índice de expresión". Son decisiones de producto y volumen. Claude te da el código, pero esas decisiones no las toma por ti.