Schema متعدد اللغات في Rails: jsonb يفوز على 19 جدول/عمود، قيود متدرجة، استعلامات ->>، توقيت الفهرسة.
how2claude ينشر كل مقال بـ 19 لغة (zh / en / ja / ko / ar / ...)، وكيف تخزن ذلك في طبقة البيانات يجب تحديده في البداية. اطلب من Claude تصميم الجدول وسيعطيك على الأرجح 19 عمود string، أو أسوأ، 19 جدولاً — "جدول articles لكل لغة". الاثنان فخ.
الإجابة الصحيحة هي جدول 1 + 3 أعمدة jsonb، كل jsonb مفتَّح حسب locale. يغطي هذا المقال لماذا يفوز هذا النهج، وكيف تكتب الـ schema، وكيف تستعلم، ومتى تُفهرس، والاتجاهات الأربع التي يخطئ فيها Claude افتراضياً.
ضع جميع الخيارات المرشحة:
الخيار A: 19 جدول (articles_zh, articles_en, ...)
- جلب مقال واحد يتطلب 19 join / 19 استعلام
- إضافة لغة 20 = CREATE TABLE
- حذف مقال → cascade على 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: "{}" (سلسلة حرفية) → Postgres يخزن السلسلة الحرفية "{}" بدلاً من json فارغ، عدم تطابق نوع عند الاستعلام
القاعدة: الافتراضي لعمود jsonb يجب أن يكون {} حرفياً كـ Ruby hash، وليس سلسلة. 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 العنوان فيها على المفتاح 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: {} (Ruby hash حرفي).
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: {} هو hash حرفي، ليس سلسلة. السلسلة "{}" تُسبِّب عدم تطابق النوع.current → en → .values.first. الطبقات العرضية لا ترى nil أبداً.title ? 'zh' (وجود)، title->>'zh' (قيمة/ترتيب). لا تسحب إلى Ruby للتصفية.القرار الحقيقي ليس "jsonb أم لا" — مع 19 لغة الإجابة واضحة. القرارات الحقيقية هي أي الحقول إلزامية مقابل اختيارية، كيف تعمل سلسلة fallback في النموذج، متى يُنتقل من "لا فهرس بعد" إلى "أضف فهرس تعبيري". هذه قرارات منتج وحجم بيانات. Claude يعطيك الكود لكن لن يتخذ هذه القرارات نيابةً عنك.