Free

Membiarkan Claude Merancang Schema Multibahasa Rails dengan jsonb

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.


Tiga opsi di meja

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.

Schema: 3 kolom jsonb + trade-off constraint

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.

Lapisan model: bungkus fallback

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.

Pola query: ->> 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').

Artikel dengan terjemahan tertentu

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.

Sortir by judul bahasa tertentu

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

Filter + sortir by locale

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

Query yang Claude default nulis salah

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

Indeks: apa yang ditambah, kapan

jsonb gak auto-indeks. Tiga kebutuhan umum map ke tiga indeks:

1. Query eksistensi / @> containment → indeks GIN

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.

2. Sortir / equality di locale spesifik → indeks ekspresi

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.

3. Gak ada query sering → gak ditambah

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

4 arah Claude default salah

Pas ngerancang schema kayak gini, insting pertama Claude sering salah. Tangkep satu-satu:

1. Pake jsonb sebagai pintu keluar "gue gak tau schema"

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

2. default: "{}" sebagai string

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

3. Semua kolom jsonb dapet null: false

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

4. Filter jsonb di lapisan aplikasi

"Gue Article.all terus .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.

Checklist

Membiarkan Claude ngerancang schema multibahasa Rails dengan jsonb — 6 aturan:

  1. Tolak 19 tabel / 19 kolom duluan. Kecuali logika tampilan beda substantif per locale, jsonb adalah satu-satunya jawaban.
  2. 3 kolom jsonb + constraint bertingkat: title null: false, default: {}; summary / content cuma default: {}.
  3. default: {} adalah literal hash, bukan string. String "{}" bikin mismatch tipe.
  4. Model sediakan accessor dengan fallback: current → en → .values.first. View gak pernah liat nil.
  5. Query di SQL: title ? 'zh' (eksistensi), title->>'zh' (nilai/sortir). Jangan tarik ke Ruby buat filter.
  6. Tunggu butuh query beneran baru indeks. Nambah GIN preventif itu over-engineering; volume kecil dengan lookup slug/id gak butuh indeks jsonb sama sekali.

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.