Schema רב-לשוני ב-Rails: jsonb מנצח 19 טבלאות/עמודות, אילוצים מדורגים, שאילתות ->>, תזמון אינדוקס.
how2claude מפרסם כל מאמר ב-19 שפות (zh / en / ja / ko / ar / ...), ואיך לאחסן את זה בשכבת הנתונים צריך להחליט מראש. תבקש מ-Claude לעצב את הטבלה וסביר שתקבל 19 עמודות string, או גרוע מכך, 19 טבלאות — "טבלת articles לכל שפה". שניהם מלכודות.
התשובה הנכונה היא טבלה אחת + 3 עמודות jsonb, כל jsonb עם locale כמפתח. המאמר מסביר למה הגישה הזו מנצחת, איך לכתוב את ה-schema, איך לשאול, מתי לאנדקס, ו-4 הכיוונים שבהם Claude טועה כברירת מחדל.
פרוש את כל המועמדים:
אפשרות A: 19 טבלאות (articles_zh, articles_en, ...)
- להביא מאמר אחד דורש 19 join / 19 שאילתות
- הוספת שפה 20 = CREATE TABLE
- מחיקת מאמר → קסקייד על 19 טבלאות
- יתרון: שאילתות טבלה-יחידה מהירות
- מציאות: אלא אם לוגיקת התצוגה באמת שונה לפי שפה (כמעט אף פעם), זו חלוקה מופרזת
אפשרות B: 19 עמודות string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- הטבלה הופכת ענקית ברוחב (3 תוכנים × 19 שפות = 57 עמודות)
- רוב התאים NULL (תרגום לתאית לרוב מגיע אחרון או לעולם לא)
- הוספת שפה 20 = ALTER TABLE ADD COLUMN × 3
- פריסת שורה דלילה, יעילות IO גרועה
אפשרות C: 3 עמודות jsonb (title jsonb, summary jsonb, content jsonb)
- שורה אחת לכל מאמר, locale הוא מפתח בתוך ה-jsonb
- הוספת שפה לא נוגעת ב-schema (פשוט מכניסים מפתח)
- אזרח ממעלה ראשונה ב-Postgres — שאילתות אטומיות, אינדקסים, שליפות חלקיות
- תחביר שאילתה קצת רועש יותר (title->>'zh') אבל אפשר לעטוף
בחר 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
למה האילוצים שונים:
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. ברירת מחדל של hash ריק הופכת כל גישה לבטוחה.
כש-Claude כותב את ה-migration הזה, הוא נוטה:
- לדלג על default: {} → קריסת nil לעיל
- להוסיף null: false לכל jsonb → summary לא יכול להיות NULL ברשומות חדשות שעדיין נמצאות בהשלמה, שובר זרימה של "כותרת קודם, תוכן אחר כך"
- לכתוב default: "{}" (literal string) → Postgres שומר את המחרוזת הליטרלית "{}" במקום json ריק, חוסר התאמת טיפוס בשאילתה
חוק: ברירת המחדל של עמודת jsonb חייבת להיות {} — literal hash של Ruby, לא מחרוזת. null: false רק על ה-jsonb החובה.
Schema לבד לא מספיק — המודל זקוק ל-accessors עם 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 ריק לא עובר וולידציה. זה התמורה של קומבו null: false + presence.
->> לעומת ->שני אופרטורי jsonb יומיומיים של Postgres:
title->>'zh' → מחזיר text (מחרוזת)title->'zh' → מחזיר jsonb (שומר על טיפוס json)99% מהזמן אתה רוצה ->> — מוציא מחרוזת ומשתמש. השתמש ב--> רק כשאתה מקונן הלאה (למשל content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? הוא אופרטור "המפתח קיים" של jsonb. מחזיר מאמרים שבהם hash ה-title מכיל את המפתח zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# שגוי 1: השוואת כל ה-jsonb כמחרוזת
Article.where(title: '{"zh": "..."}')
# שגוי 2: סינון ב-Ruby
Article.all.select { |a| a.title["zh"].present? } # מושך את כל הטבלה לזיכרון
# שגוי 3: = בלי האופרטור
Article.where("title->>'zh' = ?", "איזו כותרת") # זה נכון, אבל Claude מערבב עם שגוי 1
חוק: כל סינון / מיון / בדיקת קיום קורה ב-SQL דרך ->> או ?; אתה פונה ל-Ruby article.title["zh"] רק כשאתה מעבד עבור המשתמש.
jsonb לא מאנדקס אוטומטית. שלושה צרכים נפוצים → שלושה אינדקסים:
add_index :articles, :title, using: :gin
מאיץ title ? 'zh', title @> '{"zh": ...}' וכו'. GIN כבד בכתיבה ותופס מקום, אך חובה לשאילתות קיום מפתח/ערך ב-jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
מאיץ רק title->>'en' = ? או ORDER BY title->>'en'. אם אתה ממיין רק לפי כותרת אנגלית (למשל רשימה אלפביתית באתר אנגלי), אינדקס ביטוי אחד באנגלית מספיק.
אינדקסי jsonb הם אובייקטים יקרים. אם כמות המאמרים קטנה (אלפים) וכל השאילתות לפי id/slug, אל תאנדקס מראש את ה-jsonb. חכה עד שיעלה צורך שאילתה ברור.
ל-how2claude כרגע אין אינדקס jsonb על title/summary/content — היקף נמוך, שאילתות לפי slug, אינדקס יהיה בזבוז מקום גרידא. יתווסף כשיצמח לנקודה מסוימת (נאמר, 10 אלף מאמרים + שאילתות בכמות תכופות).
בעיצוב schema מסוג זה, התגובה הראשונה של Claude לעיתים קרובות שגויה. תפוס כל אחד:
"אני לא בטוח מה השדה הזה יחזיק, אעשה jsonb..."
הפנה מחדש: jsonb מתאים למצבים עם מבנה יציב אך מערך מפתחות פתוח או דליל-רחב — רב-לשוניות, feature flags, העדפות משתמש. לא תירוץ לדלג על עיצוב schema. אם יש לך מערך מפתחות ברור (נאמר 5 שדות קבועים), השתמש בעמודות רגילות.
default: "{}" כמחרוזתהפנה מחדש: Rails ישמור את זה כמחרוזת ליטרלית "{}" ב-Postgres, לא כ-json ריק. אחרי ה-migration, article.title["zh"] מתפוצץ (מחרוזות לא נגישות כך). חייב להיות default: {} (literal hash של Ruby).
null: falseהפנה מחדש: שאל קודם מה באמת חובה. Title חובה (מאמר בלי כותרת חסר משמעות) → null: false. summary ו-content יכולים להיות ריקים (שלב הטיוטה) → רק default: {}, בלי null: false. דרג אילוצים; אל תבזבז פצצות שטיח.
"אני עושה
Article.allואז.select { |a| a.title["zh"] }..."
הפנה מחדש: כל המהות של jsonb היא תמיכה מקורית ב-SQL. כל סינון / קיום / מיון חי ב-Postgres דרך ->> / ? / @>. תגרור חזרה ל-Ruby, וברגע שהטבלה מגיעה לאלף שורות, אתה מת.
לתת ל-Claude לעצב schema רב-לשוני של Rails ב-jsonb — 6 כללים:
null: false, default: {}; summary / content רק default: {}.default: {} הוא literal hash, לא מחרוזת. מחרוזת "{}" גורמת לחוסר התאמת טיפוס.current → en → .values.first. תצוגות לעולם לא רואות nil.title ? 'zh' (קיום), title->>'zh' (ערך/מיון). אל תגרור ל-Ruby לסינון.ההחלטה האמיתית אינה "jsonb או לא" — עם 19 שפות התשובה ברורה. ההחלטות האמיתיות הן אילו שדות חובה לעומת אופציונליים, איך שרשרת ה-fallback של המודל עובדת, מתי לעבור מ"עדיין בלי אינדקס" ל"הוסף אינדקס ביטוי". אלה החלטות מוצר ונפח. Claude נותן לך קוד אך לא מקבל את ההחלטות האלה במקומך.