Çok dilli Rails schema: jsonb 19 tablo/kolonu yener, kademeli kısıtlar, ->> sorgular, indeks zamanlaması.
how2claude her makaleyi 19 dilde yayınlıyor (zh / en / ja / ko / ar / ...), ve bunu veri katmanında nasıl saklayacağın en başta belirlenmeli. Claude'a bu tabloyu tasarlatırsan muhtemelen sana 19 string kolon verir, ya da beteri, 19 tablo — "dil başına bir articles tablosu". İkisi de tuzak.
Doğru cevap 1 tablo + 3 jsonb kolon, her jsonb locale'lerin anahtar olduğu şekilde. Bu yazı neden bu yaklaşımın kazandığını, schema'yı nasıl yazacağını, nasıl sorgulayacağını, ne zaman indeksleyeceğini ve Claude'un varsayılan olarak saptığı 4 yönü anlatıyor.
Aday yaklaşımları ortaya dök:
Seçenek A: 19 tablo (articles_zh, articles_en, ...)
- Tek bir makaleyi çekmek 19 join / 19 sorgu ister
- 20. dili eklemek = CREATE TABLE
- Bir makaleyi silmek 19 tabloya cascade olur
- Artı: tek tablo sorguları hızlı
- Gerçeklik: sunum mantığı gerçekten dile göre farklı değilse (neredeyse hiç), bu aşırı bölümleme
Seçenek B: 19 string kolon (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Tablo devasa genişler (3 içerik × 19 dil = 57 kolon)
- Çoğu hücre NULL (Tayca çevirisi çoğunlukla en son gelir ya da hiç gelmez)
- 20. dili eklemek = ALTER TABLE ADD COLUMN × 3
- Satır içi alanlar seyrek, IO verimliliği kötü
Seçenek C: 3 jsonb kolon (title jsonb, summary jsonb, content jsonb)
- Makale başına bir satır, locale jsonb'nin içindeki anahtar
- Dil eklemek schema'ya dokunmaz (yeni anahtar eklemek yeter)
- Postgres'te birinci sınıf vatandaş — atomik sorgular, indeksler, kısmi fetch
- Sorgu sözdizimi biraz daha gürültülü (title->>'zh') ama sarmalanabilir
C'yi seç. Bu yazının geri kalanı C'yi doğru yapmakla ilgili.
how2claude'un gerçek articles tablosu (kısmen):
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
# ... diğer bigint, timestamp vs.
end
Kısıtların neden farklı olduğu:
title: null: false + default: {}. Başlık zorunlu — bir makalenin en az bir dilde başlığı olmalı. Sadece en bile olsa, en az o. jsonb bütün olarak NULL olamaz.summary / content: sadece default: {}, NULL serbest. Bunlar boş olabilir (oluşturulduktan hemen sonra, içerik yazılmadan önce), o yüzden gevşet.default: {} zorunlu. Atla, yeni Article.new'lanmış bir kayıtta title = nil olur ve hemen article.title["en"] patlar: NoMethodError: undefined method '[]' for nil:NilClass. Varsayılanı boş hash yapmak tüm erişimleri güvenli kılar.
Claude bu migration'ı yazarken sık sık:
- default: {}'i atlar → yukarıdaki nil çöker
- Her jsonb'ye null: false ekler → summary yeni kayıtlarda hâlâ doldurulurken NULL olamaz, "önce başlık sonra içerik" akışı kırılır
- default: "{}" (string literali) yazar → Postgres literal string "{}"'i saklar, boş json değil, sorgu anında tip uyuşmazlığı
Kural: jsonb kolonunun varsayılanı {} Ruby hash literali olmalı, string değil. null: false sadece zorunlu jsonb'ye.
Schema tek başına yetmez — model'in fallback destekli erişimcilerine ihtiyacı var:
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
Üç kademeli fallback: mevcut locale → en → ne varsa o. Japonca bir makaleye giren Çinli okuyucu, Japonca yoksa İngilizce'ye düşer; İngilizce de yoksa "orada her ne varsa"ya düşer, asla nil olmaz.
validates :title, presence: true {}'i otomatik işler — ActiveRecord boş hash'i blank sayar, bu yüzden boş title doğrulamadan geçemez. null: false + presence kombosunun getirisi bu.
->> vs ->Postgres jsonb'sinin günlük iki operatörü:
title->>'zh' → text döner (string)title->'zh' → jsonb döner (json tipini korur)Zamanın %99'unda ->> istersin — string çekip kullanırsın. ->'u sadece daha derine inerken kullan (örn. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? jsonb'nin "anahtar var" operatörüdür. Başlık hash'inde zh anahtarı olan makaleleri döner.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Yanlış 1: tüm jsonb'yi string gibi karşılaştır
Article.where(title: '{"zh": "..."}')
# Yanlış 2: Ruby tarafında filtrele
Article.all.select { |a| a.title["zh"].present? } # tüm tabloyu belleğe çeker
# Yanlış 3: operatörsüz = kullan
Article.where("title->>'zh' = ?", "bir başlık") # bu doğru ama Claude Yanlış 1 ile karıştırır
Kural: tüm filtre / sıralama / varlık kontrolü SQL'de ->> veya ? ile olur; sadece kullanıcıya göstermek için Ruby'de article.title["zh"]'a uzan.
jsonb otomatik indekslenmez. Üç yaygın ihtiyaç üç tür indekse karşılık gelir:
add_index :articles, :title, using: :gin
title ? 'zh', title @> '{"zh": ...}' vs. hızlandırır. GIN yazmada ağır ve yer kaplar, ama jsonb'nin anahtar/değer varlık sorguları için zorunlu.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Sadece title->>'en' = ? veya ORDER BY title->>'en''i hızlandırır. Sadece İngilizce başlığa göre sıralıyorsan (örn. İngiliz site alfabetik listesi), bir İngilizce ifade indeksi yeter.
jsonb indeksleri pahalı nesnelerdir. Makale sayısı düşükse (binler) ve tüm sorgular id/slug ile ise, jsonb'yi önceden indeksleme. Net bir sorgu ihtiyacı oluşana kadar bekle.
how2claude'da şu anda title/summary/content üzerinde jsonb indeksi yok — hacim düşük, sorgular slug aramaları, indeks sadece yer israfı. Toplu sorgular sıklaşmaya başladığında (diyelim 10k makale) eklenecek.
Bu tür schema tasarımında Claude'un ilk refleksi çoğu zaman yanılır. Her birini yakala:
"Bu alanın ne tutacağını bilmiyorum, jsonb yapayım..."
Yönlendir: jsonb yapısı kararlı ama anahtar kümesi açık uçlu ya da seyrek-geniş durumlara uyar — çok dilli, feature flags, kullanıcı tercihleri. Schema tasarımını atlama bahanesi değil. Net bir anahtar kümen varsa (mesela 5 sabit alan), normal kolonlar kullan.
default: "{}" string olarakYönlendir: Rails bunu Postgres'te literal string "{}" olarak saklar, boş json olarak değil. Migration sonrası article.title["zh"] patlar (string'ler böyle subscript olmaz). default: {} olmalı (Ruby hash literali).
null: falseYönlendir: Önce hangisinin gerçekten zorunlu olduğunu sor. Title zorunlu (başlıksız makale saçma) → null: false. summary ve content boş olabilir (taslak aşaması) → sadece default: {}, null: false yok. Kısıtları kademelendir; toptan atma.
"
Article.allyapıp.select { |a| a.title["zh"] }..."
Yönlendir: jsonb'nin tüm olayı SQL'de yerel destek. Tüm filtre / varlık / sıralama Postgres'te ->> / ? / @> ile yaşar. Ruby'ye geri sürüklersen, tablo bin satıra ulaştığı an ölürsün.
Claude'a jsonb ile Rails çok dilli schema tasarlatma — 6 kural:
null: false, default: {}; summary / content sadece default: {}.default: {} bir hash literalidir, string değil. String "{}" tip uyuşmazlığı yaratır.current → en → .values.first. View'lar asla nil görmez.title ? 'zh' (varlık), title->>'zh' (değer/sıralama). Filtrelemek için Ruby'ye çekme.Gerçek karar "jsonb mi değil mi" değil — 19 dilde cevap zaten belli. Gerçek kararlar şunlar: hangi alanlar zorunlu vs isteğe bağlı, modelin fallback zinciri nasıl işlesin, "henüz indeks yok"tan "ifade indeksi ekle"ye ne zaman geçilsin. Bunlar ürün ve hacim kararları. Claude kodu verir ama bu kararları senin yerine vermez.