Schema Rails หลายภาษา: jsonb ชนะ 19 ตาราง/คอลัมน์, constraint แบ่งชั้น, query ->>, เวลาใส่ index
how2claude ปล่อยทุกบทความใน 19 ภาษา (zh / en / ja / ko / ar / ...) และวิธีเก็บในชั้นข้อมูลต้องตัดสินใจตั้งแต่ต้น ขอ Claude ออกแบบตาราง มักจะให้ 19 คอลัมน์ string หรือแย่กว่า 19 ตาราง — "ตาราง articles หนึ่งต่อภาษา" สองอย่างเป็นกับดัก
คำตอบที่ถูกคือ ตาราง 1 + คอลัมน์ jsonb 3, แต่ละ jsonb ใช้ locale เป็น key บทความนี้อธิบายว่าทำไมวิธีนี้ชนะ เขียน schema ยังไง query ยังไง index เมื่อไร และ 4 ทิศที่ Claude default ผิด
เอาผู้สมัครทั้งหมดวางไว้ก่อน:
ตัวเลือก A: 19 ตาราง (articles_zh, articles_en, ...)
- ดึงบทความหนึ่งต้อง join 19 ครั้ง / query 19 ครั้ง
- เพิ่มภาษาที่ 20 = CREATE TABLE
- ลบบทความ → cascade 19 ตาราง
- ข้อดี: query ตารางเดียวเร็ว
- ความจริง: ถ้า logic การแสดงผลไม่ได้ต่างกันจริง ๆ ตามภาษา (เกือบไม่มี), นี่คือ over-partition
ตัวเลือก B: 19 คอลัมน์ string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- ตารางกว้างมหึมา (3 เนื้อหา × 19 ภาษา = 57 คอลัมน์)
- เซลล์ส่วนใหญ่ NULL (ไทยมักมาทีหลังหรือไม่มาเลย)
- เพิ่มภาษาที่ 20 = ALTER TABLE ADD COLUMN × 3
- layout แถวเบาบาง IO ไม่มีประสิทธิภาพ
ตัวเลือก C: คอลัมน์ jsonb 3 (title jsonb, summary jsonb, content jsonb)
- หนึ่งแถวต่อบทความ locale คือ key ภายใน jsonb
- เพิ่มภาษาไม่แตะ schema (แค่ใส่ key ใหม่)
- พลเมืองชั้นหนึ่งใน Postgres — query อะตอมิก, index, fetch บางส่วน
- syntax query ดังขึ้นนิด (title->>'zh') แต่ wrap ได้
เลือก C ส่วนที่เหลือของบทความนี้เกี่ยวกับการทำ C ให้ถูก
ตาราง articles ของ how2claude จริง (ตัดตอน):
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 อื่น ๆ ฯลฯ
end
ทำไม constraint ต่างกัน:
title: null: false + default: {} ชื่อเรื่องบังคับ — บทความต้องมีชื่อเรื่องอย่างน้อยภาษาเดียว แม้แค่ en ก็ต้อง jsonb ทั้งก้อนห้าม NULLsummary / content: แค่ default: {} อนุญาต NULL สองตัวนี้อาจว่างได้ (พอสร้างเสร็จยังไม่ได้เขียนเนื้อหา) ผ่อนdefault: {} จำเป็น ถ้าข้าม Article.new ที่เพิ่งสร้างจะมี title = nil แล้วทันที article.title["en"] ระเบิด: NoMethodError: undefined method '[]' for nil:NilClass ตั้ง default เป็น hash ว่างทำให้ทุกการเข้าถึงปลอดภัย
เวลา Claude เขียน migration นี้มักจะ:
- ข้าม default: {} → crash nil ข้างบน
- ใส่ null: false ให้ทุก jsonb → summary เป็น NULL ไม่ได้ในบันทึกใหม่ที่กำลังเติมอยู่ ทำ flow "ชื่อเรื่องก่อนเนื้อหาทีหลัง" พัง
- เขียน default: "{}" (string literal) → Postgres เก็บ string literal "{}" แทนที่จะเป็น json ว่าง ทำให้ type ไม่ตรงเวลา query
กฎ: default ของคอลัมน์ jsonb ต้องเป็น {} ซึ่งเป็น Ruby hash literal ไม่ใช่ string ใส่ null: false เฉพาะ jsonb ที่บังคับ
schema อย่างเดียวไม่พอ — model ต้องมี accessor พร้อม 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 สามขั้น: locale ปัจจุบัน → en → อะไรก็ได้ที่มี ผู้อ่านจีนเจอบทความญี่ปุ่น ถ้าไม่มีญี่ปุ่นตกมาอังกฤษ ถ้าไม่มีอังกฤษด้วยตกมา "อะไรก็ได้ที่มี" ไม่มีทาง nil
validates :title, presence: true จัดการ {} อัตโนมัติ — ActiveRecord ถือว่า hash ว่างคือ blank ดังนั้น title ว่างผ่าน validation ไม่ได้ นี่คือผลประโยชน์ของคู่ null: false + presence
->> กับ ->สอง operator jsonb ใน Postgres ที่ใช้ทุกวัน:
title->>'zh' → คืน text (string)title->'zh' → คืน jsonb (รักษา type json)99% ของเวลาคุณต้องการ ->> — ดึง string มาใช้ ใช้ -> เฉพาะตอนจะ nest ต่อ (เช่น content->'metadata'->>'author')
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? คือ operator "key มีอยู่" ของ jsonb คืนบทความที่ hash title มี key zh
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# ผิด 1: เทียบ jsonb ทั้งก้อนเป็น string
Article.where(title: '{"zh": "..."}')
# ผิด 2: กรองใน Ruby
Article.all.select { |a| a.title["zh"].present? } # ลากทั้งตารางมาใน memory
# ผิด 3: = ไม่ใช้ operator
Article.where("title->>'zh' = ?", "ชื่อเรื่องบางชื่อ") # อันนี้ถูก แต่ Claude ปนกับ ผิด 1
กฎ: กรอง / เรียง / เช็คการมีอยู่ใด ๆ เกิดใน SQL ผ่าน ->> หรือ ? ไปหา Ruby article.title["zh"] เฉพาะเวลา render ให้ผู้ใช้
jsonb ไม่ index อัตโนมัติ สามความต้องการทั่วไป → สามประเภท index:
add_index :articles, :title, using: :gin
เร่ง title ? 'zh', title @> '{"zh": ...}' ฯลฯ GIN เขียนหนักและกินที่ แต่จำเป็นสำหรับ query การมีอยู่ของ key/value ใน jsonb
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
เร่งเฉพาะ title->>'en' = ? หรือ ORDER BY title->>'en' ถ้าเรียงตามชื่อเรื่องอังกฤษเท่านั้น (เช่น ลิสต์ตามตัวอักษรของเว็บอังกฤษ) expression index อังกฤษตัวเดียวพอ
index jsonb คือ object ที่แพง ถ้าจำนวนบทความเล็ก (พัน) และ query ทุกตัว by id/slug อย่า pre-index jsonb รอจนมีความต้องการ query ชัดเจน
ตอนนี้ how2claude ไม่มี index jsonb ที่ title/summary/content — ปริมาณต่ำ query ตาม slug เป็นส่วนมาก index จะเปลือง space เฉย ๆ เพิ่มเมื่อโตถึงจุดหนึ่ง (เช่น 10k บทความ + query รวมเริ่มมีบ่อย)
ในงานออกแบบ schema แบบนี้ ปฏิกิริยาแรกของ Claude มักผิด จับทีละอัน:
"ผมไม่แน่ใจว่า field นี้จะเก็บอะไร ให้ jsonb เลย..."
เปลี่ยนทิศ: jsonb เหมาะกับสถานการณ์ โครงสร้างเสถียรแต่ set ของ key เปิดหรือเบาบางกว้าง — หลายภาษา, feature flags, ค่ากำหนดผู้ใช้ ไม่ใช่ข้ออ้างข้ามการออกแบบ schema ถ้าคุณมี set key ชัดเจน (เช่น 5 field คงที่) ใช้ column ปกติ
default: "{}" เป็น stringเปลี่ยนทิศ: Rails จะเก็บเป็น string literal "{}" ใน Postgres ไม่ใช่ json ว่าง หลัง migration article.title["zh"] ระเบิด (string subscript แบบนี้ไม่ได้) ต้องเป็น default: {} (Ruby hash literal)
null: falseเปลี่ยนทิศ: ถามก่อนว่าอันไหนบังคับจริง title บังคับ (บทความไม่มีชื่อเรื่องไม่มีความหมาย) → null: false summary กับ content อาจว่างได้ (ช่วง draft) → แค่ default: {} ไม่ใส่ null: false จัดลำดับ constraint อย่าเหวี่ยงแห
"ผม
Article.allแล้วก็.select { |a| a.title["zh"] }..."
เปลี่ยนทิศ: จุดเด่นของ jsonb คือ รองรับ SQL native การกรอง / การมีอยู่ / การเรียงทั้งหมดอยู่ใน Postgres ผ่าน ->> / ? / @> ลากกลับ Ruby พอตารางแตะพันแถวเมื่อไร คุณตาย
ให้ Claude ออกแบบ schema Rails หลายภาษาด้วย jsonb — 6 ข้อ:
null: false, default: {}; summary / content แค่ default: {}default: {} คือ hash literal ไม่ใช่ string string "{}" ทำให้ type mismatchcurrent → en → .values.first view ไม่เห็น niltitle ? 'zh' (การมีอยู่), title->>'zh' (ค่า/เรียง) อย่าลากกลับ Ruby มากรองการตัดสินใจจริงไม่ใช่ "jsonb หรือไม่" — ด้วย 19 ภาษาคำตอบชัดเจน การตัดสินใจจริงคือ field ไหนบังคับ vs ตัวเลือก, chain fallback ของ model ทำงานยังไง, เมื่อไรเปลี่ยนจาก "ยังไม่มี index" เป็น "เพิ่ม expression index" เหล่านี้คือการตัดสินใจของ product และปริมาณข้อมูล Claude ให้ code แต่ไม่ตัดสินใจพวกนี้แทนคุณ