Free

Que Claude diseñe schemas multilingües en Rails con jsonb

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.


Las tres opciones sobre la mesa

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.

Schema: 3 columnas jsonb + compromiso de restricciones

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.

Capa del modelo: envolver el fallback

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.

Patrones de consulta: ->> 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').

Artículos con una traducción específica

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.

Ordenar por título de un idioma específico

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

Filtrar + ordenar por locale

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

Consultas que Claude escribe mal por defecto

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

Índices: qué añadir y cuándo

jsonb no se indexa solo. Tres necesidades típicas se mapean a tres índices:

1. Consultas de existencia / @> contenedoras → índice GIN

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.

2. Ordenar / igualdad sobre un locale concreto → índice de expresión

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.

3. Sin consultas frecuentes → no añadir

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

Las 4 direcciones en las que Claude se equivoca por defecto

Al diseñar este tipo de schema, el primer instinto de Claude suele fallar. Atrapa cada uno:

1. Usa jsonb como vía de escape "no sé el schema"

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

2. default: "{}" como cadena

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

3. A cada columna jsonb null: false

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

4. Filtra jsonb en la capa de aplicación

"Hago Article.all y 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.

Checklist

Que Claude diseñe schemas multilingües Rails con jsonb — 6 reglas:

  1. Descarta 19 tablas / 19 columnas primero. A menos que la lógica de presentación difiera sustancialmente por locale, jsonb es la única respuesta.
  2. 3 columnas jsonb + restricciones por niveles: title null: false, default: {}; summary / content solo default: {}.
  3. default: {} es un literal de hash, no una cadena. La cadena "{}" causa desajuste de tipo.
  4. Modelo con accesores con fallback: current → en → .values.first. Las vistas nunca ven nil.
  5. Consultas en SQL: title ? 'zh' (existencia), title->>'zh' (valor/orden). No los traigas a Ruby para filtrar.
  6. Espera a tener necesidad real para indexar. Añadir GIN preventivamente es sobreingeniería; volúmenes bajos con consultas slug/id no necesitan índice jsonb alguno.

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.