Schema Rails multibahasa: jsonb menang atas 19 tabel/kolom, constraint bertingkat, query ->>, kapan indeks.
how2claude nerbitin tiap artikel di 19 bahasa (zh / en / ja / ko / ar / ...), dan gimana nyimpen itu di lapisan data harus diputusin dari awal. Minta Claude ngerancang tabel dan kemungkinan besar dia bakal kasih 19 kolom string, atau lebih parah, 19 tabel — "satu tabel articles per bahasa". Dua-duanya jebakan.
Jawaban yang bener adalah 1 tabel + 3 kolom jsonb, tiap jsonb di-key pake locale. Artikel ini bahas kenapa pendekatan ini menang, gimana nulis schema, gimana query, kapan indeks, dan 4 arah Claude default-nya salah.
Taruh semua kandidat di meja dulu:
Opsi A: 19 tabel (articles_zh, articles_en, ...)
- Ambil satu artikel butuh 19 join / 19 query
- Nambah bahasa ke-20 = CREATE TABLE
- Hapus satu artikel cascading ke 19 tabel
- Plus: query tabel tunggal cepet
- Realita: kecuali logika tampilan beda banget per bahasa (hampir gak pernah), ini over-partisi
Opsi B: 19 kolom string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Tabel jadi besar banget (3 jenis × 19 bahasa = 57 kolom)
- Kebanyakan sel NULL (terjemahan Thailand biasanya dateng terakhir atau gak pernah)
- Nambah bahasa ke-20 = ALTER TABLE ADD COLUMN × 3
- Layout baris jarang, IO efisiensi jelek
Opsi C: 3 kolom jsonb (title jsonb, summary jsonb, content jsonb)
- Satu baris per artikel, locale adalah key di dalam jsonb
- Nambah bahasa gak nyentuh schema (cukup insert key baru)
- Warga kelas satu di Postgres — query atomik, indeks, partial fetch
- Sintaks query agak berisik (title->>'zh') tapi bisa di-wrap
Pilih C. Sisa artikel ini soal nge-do C dengan bener.
Tabel articles asli how2claude (kutipan):
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
# ... bigint, timestamp lain dll.
end
Kenapa constraint-nya beda:
title: null: false + default: {}. Judul wajib — artikel harus punya minimal satu bahasa judul. Walau cuma en, minimal itu. jsonb keseluruhan gak boleh NULL.summary / content: cuma default: {}, NULL diizinin. Dua ini boleh kosong (misal pas baru dibuat, konten belum ditulis), jadi dikendorin.default: {} wajib. Kalo dilewatin, Article.new yang baru dibikin punya title = nil, dan terus article.title["en"] meledak: NoMethodError: undefined method '[]' for nil:NilClass. Default hash kosong bikin semua akses aman.
Pas Claude nulis migration ini, dia sering:
- Lewatin default: {} → crash nil di atas
- Tambahin null: false ke tiap jsonb → summary gak bisa NULL di record baru yang masih diisi, rusakin flow "judul dulu, konten nanti"
- Tulis default: "{}" (string literal) → Postgres nyimpen literal string "{}" bukannya json kosong, mismatch tipe pas query
Aturan: default kolom jsonb harus {} literal hash Ruby, bukan string. null: false cuma buat jsonb yang wajib.
Schema aja gak cukup — model butuh accessor dengan 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 tiga langkah: locale sekarang → en → apapun yang ada. Pembaca Mandarin yang buka artikel Jepang, kalo Jepang gak ada jatuh ke Inggris; Inggris juga gak ada, jatuh ke "yang ada aja", gak pernah nil.
validates :title, presence: true handle {} otomatis — ActiveRecord ngantepin hash kosong sebagai blank, jadi title kosong gagal validasi. Ini payoff kombo null: false + presence.
->> vs ->Dua operator jsonb Postgres yang sehari-hari:
title->>'zh' → return text (string)title->'zh' → return jsonb (pertahanin tipe json)99% waktu lo mau ->> — tarik string dan pake. Pake -> cuma pas mau nesting lanjut (misal content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? operator "key ada" dari jsonb. Return artikel yang hash title-nya punya key zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Salah 1: banding jsonb utuh sebagai string
Article.where(title: '{"zh": "..."}')
# Salah 2: filter di Ruby
Article.all.select { |a| a.title["zh"].present? } # tarik seluruh tabel ke memory
# Salah 3: pake = tanpa operator
Article.where("title->>'zh' = ?", "judul apa") # ini bener, tapi Claude nyampur sama Salah 1
Aturan: filter / sortir / cek eksistensi apapun di lapisan SQL pake ->> atau ?; cuma ambil article.title["zh"] di Ruby pas render buat user.
jsonb gak auto-indeks. Tiga kebutuhan umum map ke tiga indeks:
add_index :articles, :title, using: :gin
Percepat title ? 'zh', title @> '{"zh": ...}', dll. GIN berat di write dan makan space, tapi wajib buat query eksistensi key/value jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Cuma percepat title->>'en' = ? atau ORDER BY title->>'en'. Kalo cuma sortir by judul Inggris (misal list alfabet situs Inggris), satu indeks ekspresi Inggris cukup.
Indeks jsonb itu objek mahal. Kalo total artikel kecil (ribuan) dan semua query by id/slug, jangan pre-indeks jsonb. Tunggu sampe ada kebutuhan query jelas.
how2claude sekarang gak ada indeks jsonb di title/summary/content — volume kecil, query lookup slug, indeks cuma buang space. Tambah pas volume tumbuh ke titik tertentu (misal 10 ribu artikel + bulk query sering muncul).
Pas ngerancang schema kayak gini, insting pertama Claude sering salah. Tangkep satu-satu:
"Gue gak yakin field ini nyimpen apa, jadiin jsonb aja..."
Arahin ulang: jsonb cocok buat situasi dengan struktur stabil tapi set key terbuka atau jarang-lebar — multibahasa, feature flags, preferensi user. Bukan alasan buat skip desain schema. Kalo udah punya set key jelas (misal 5 field tetap), pake kolom biasa.
default: "{}" sebagai stringArahin ulang: Rails bakal simpan ini sebagai string literal "{}" di Postgres, bukan json kosong. Setelah migration, article.title["zh"] meledak (string gak bisa di-subscript gitu). Harus default: {} (literal hash Ruby).
null: falseArahin ulang: Tanya dulu mana yang beneran wajib. Title wajib (artikel tanpa judul gak masuk akal) → null: false. summary dan content boleh kosong (fase draft) → cuma default: {}, gak pake null: false. Constraint bertingkat, bukan diseragamin.
"Gue
Article.allterus.select { |a| a.title["zh"] }..."
Arahin ulang: Inti jsonb itu dukungan SQL native. Semua filter / eksistensi / sortir hidup di Postgres via ->> / ? / @>. Tarik balik ke Ruby, dan saat tabel nyentuh seribu baris, lo mati.
Membiarkan Claude ngerancang schema multibahasa Rails dengan jsonb — 6 aturan:
null: false, default: {}; summary / content cuma default: {}.default: {} adalah literal hash, bukan string. String "{}" bikin mismatch tipe.current → en → .values.first. View gak pernah liat nil.title ? 'zh' (eksistensi), title->>'zh' (nilai/sortir). Jangan tarik ke Ruby buat filter.Keputusan beneran bukan "jsonb atau nggak" — dengan 19 bahasa jawabannya jelas. Keputusan beneran itu field mana wajib vs opsional, rantai fallback model jalan gimana, kapan pindah dari "belum indeks" ke "tambah indeks ekspresi". Itu keputusan produk dan volume. Claude ngasih kode tapi gak bikin keputusan itu buat lo.