Free

ให้ Claude ออกแบบ schema Rails หลายภาษาด้วย jsonb

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 ให้ถูก

Schema: คอลัมน์ jsonb 3 + ทางเลือก constraint

ตาราง 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 ทั้งก้อนห้าม NULL
  • summary / 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 ที่บังคับ

ชั้น model: ห่อ fallback

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

รูปแบบ query: ->> กับ ->

สอง 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")

กรอง + เรียงตาม locale

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

Query ที่ Claude เขียนผิดโดย default

# ผิด 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 ให้ผู้ใช้

Index: ใส่อะไร เมื่อไร

jsonb ไม่ index อัตโนมัติ สามความต้องการทั่วไป → สามประเภท index:

1. query การมีอยู่ / @> containment → GIN index

add_index :articles, :title, using: :gin

เร่ง title ? 'zh', title @> '{"zh": ...}' ฯลฯ GIN เขียนหนักและกินที่ แต่จำเป็นสำหรับ query การมีอยู่ของ key/value ใน jsonb

2. เรียง / equality บน locale เฉพาะ → expression index

add_index :articles, "(title->>'en')", name: "idx_articles_title_en"

เร่งเฉพาะ title->>'en' = ? หรือ ORDER BY title->>'en' ถ้าเรียงตามชื่อเรื่องอังกฤษเท่านั้น (เช่น ลิสต์ตามตัวอักษรของเว็บอังกฤษ) expression index อังกฤษตัวเดียวพอ

3. ไม่มี query ถี่ → ไม่เพิ่ม

index jsonb คือ object ที่แพง ถ้าจำนวนบทความเล็ก (พัน) และ query ทุกตัว by id/slug อย่า pre-index jsonb รอจนมีความต้องการ query ชัดเจน

ตอนนี้ how2claude ไม่มี index jsonb ที่ title/summary/content — ปริมาณต่ำ query ตาม slug เป็นส่วนมาก index จะเปลือง space เฉย ๆ เพิ่มเมื่อโตถึงจุดหนึ่ง (เช่น 10k บทความ + query รวมเริ่มมีบ่อย)

4 ทิศที่ Claude default ผิด

ในงานออกแบบ schema แบบนี้ ปฏิกิริยาแรกของ Claude มักผิด จับทีละอัน:

1. ใช้ jsonb เป็นทางออก "ไม่รู้ schema"

"ผมไม่แน่ใจว่า field นี้จะเก็บอะไร ให้ jsonb เลย..."

เปลี่ยนทิศ: jsonb เหมาะกับสถานการณ์ โครงสร้างเสถียรแต่ set ของ key เปิดหรือเบาบางกว้าง — หลายภาษา, feature flags, ค่ากำหนดผู้ใช้ ไม่ใช่ข้ออ้างข้ามการออกแบบ schema ถ้าคุณมี set key ชัดเจน (เช่น 5 field คงที่) ใช้ column ปกติ

2. default: "{}" เป็น string

เปลี่ยนทิศ: Rails จะเก็บเป็น string literal "{}" ใน Postgres ไม่ใช่ json ว่าง หลัง migration article.title["zh"] ระเบิด (string subscript แบบนี้ไม่ได้) ต้องเป็น default: {} (Ruby hash literal)

3. ทุก column jsonb ได้ null: false

เปลี่ยนทิศ: ถามก่อนว่าอันไหนบังคับจริง title บังคับ (บทความไม่มีชื่อเรื่องไม่มีความหมาย) → null: false summary กับ content อาจว่างได้ (ช่วง draft) → แค่ default: {} ไม่ใส่ null: false จัดลำดับ constraint อย่าเหวี่ยงแห

4. กรอง jsonb ในชั้น application

"ผม Article.all แล้วก็ .select { |a| a.title["zh"] }..."

เปลี่ยนทิศ: จุดเด่นของ jsonb คือ รองรับ SQL native การกรอง / การมีอยู่ / การเรียงทั้งหมดอยู่ใน Postgres ผ่าน ->> / ? / @> ลากกลับ Ruby พอตารางแตะพันแถวเมื่อไร คุณตาย

Checklist

ให้ Claude ออกแบบ schema Rails หลายภาษาด้วย jsonb — 6 ข้อ:

  1. ตัด 19 ตาราง / 19 column ออกก่อน เว้นแต่ logic การแสดงผลต่างกันจริง ๆ ตาม locale, jsonb คือคำตอบเดียว
  2. 3 column jsonb + constraint แบ่งชั้น: title null: false, default: {}; summary / content แค่ default: {}
  3. default: {} คือ hash literal ไม่ใช่ string string "{}" ทำให้ type mismatch
  4. Model มี accessor พร้อม fallback: current → en → .values.first view ไม่เห็น nil
  5. Query ใน SQL: title ? 'zh' (การมีอยู่), title->>'zh' (ค่า/เรียง) อย่าลากกลับ Ruby มากรอง
  6. รอให้มีความต้องการ query จริงก่อนใส่ index ใส่ GIN ป้องกันไว้ก่อนคือ over-engineering ปริมาณเล็กที่ lookup ตาม slug/id ไม่ต้องการ index jsonb ใด ๆ

การตัดสินใจจริงไม่ใช่ "jsonb หรือไม่" — ด้วย 19 ภาษาคำตอบชัดเจน การตัดสินใจจริงคือ field ไหนบังคับ vs ตัวเลือก, chain fallback ของ model ทำงานยังไง, เมื่อไรเปลี่ยนจาก "ยังไม่มี index" เป็น "เพิ่ม expression index" เหล่านี้คือการตัดสินใจของ product และปริมาณข้อมูล Claude ให้ code แต่ไม่ตัดสินใจพวกนี้แทนคุณ