Free

ترك Claude يصمم schema متعدد اللغات في Rails باستخدام jsonb

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 بشكل صحيح.

Schema: 3 أعمدة jsonb + مقايضة القيود

جدول 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 الإلزامي.

طبقة النموذج: تغليف fallback

الـ 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")

تصفية + ترتيب حسب locale

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

الاستعلامات التي يكتبها Claude خطأً افتراضياً

# خطأ 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 لا يُفهرس تلقائياً. ثلاث احتياجات شائعة تُخطَّط إلى ثلاثة فهارس:

1. استعلامات الوجود / @> الاحتواء → فهرس GIN

add_index :articles, :title, using: :gin

يُسرِّع title ? 'zh', title @> '{"zh": ...}'، إلخ. GIN ثقيل الكتابة ويستهلك مساحة، لكنه إلزامي لاستعلامات وجود المفاتيح/القيم في jsonb.

2. ترتيب / مساواة على locale محدد → فهرس تعبيري

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

يُسرِّع فقط title->>'en' = ? أو ORDER BY title->>'en'. إذا كنت تُرتِّب فقط حسب العنوان الإنجليزي (مثل قائمة موقع إنجليزي أبجدية)، فهرس تعبيري إنجليزي واحد يكفي.

3. لا استعلام عالي التردد → لا تضف

فهارس jsonb كائنات باهظة. إذا كان عدد المقالات صغيراً (آلاف) وكل الاستعلامات حسب id/slug، لا تُفهرس jsonb مسبقاً. انتظر حتى تظهر حاجة استعلامية واضحة.

how2claude حالياً لا يحتوي فهرس jsonb على title/summary/content — الحجم منخفض، الاستعلامات حسب slug، الفهرس سيكون مجرد هدر مساحة. يُضاف عند النمو إلى نقطة معينة (مثلاً 10 آلاف مقال + استعلامات مجمعة متكررة).

الاتجاهات الأربعة التي يخطئ فيها Claude افتراضياً

عند تصميم مثل هذا الـ schema، أول رد فعل لـ Claude غالباً خطأ. اعترض كل منها:

1. يستخدم jsonb كمخرج "لا أعرف الـ schema"

"لست متأكداً مما سيحتويه هذا الحقل، سأجعله jsonb..."

أعد التوجيه: jsonb يناسب حالات بنية مستقرة لكن مجموعة مفاتيح مفتوحة أو متناثرة-واسعة — متعدد اللغات، feature flags، تفضيلات المستخدم. ليس عذراً لتخطي تصميم الـ schema. إذا كانت لديك مجموعة مفاتيح واضحة (مثلاً 5 حقول ثابتة)، استخدم أعمدة عادية.

2. default: "{}" كسلسلة

أعد التوجيه: Rails سيحفظ هذه كسلسلة حرفية "{}" في Postgres، ليس كـ json فارغ. بعد الـ migration، article.title["zh"] يُفجِّر (السلاسل لا تُفهرس هكذا). يجب أن يكون default: {} (Ruby hash حرفي).

3. كل عمود jsonb يحصل على null: false

أعد التوجيه: اسأل أولاً أيها إلزامي فعلاً. Title إلزامي (مقال بلا عنوان غير منطقي) → null: false. summary و content يمكن أن يكونا فارغين (مرحلة المسودة) → فقط default: {}، بدون null: false. درّج القيود؛ لا ترشها بالجملة.

4. تصفية jsonb في طبقة التطبيق

"أعمل Article.all ثم .select { |a| a.title["zh"] }..."

أعد التوجيه: جوهر jsonb هو الدعم الأصلي في SQL. كل تصفية / وجود / ترتيب تعيش في Postgres عبر ->> / ? / @>. اسحبها إلى Ruby واللحظة التي يضرب فيها الجدول ألف صف، أنت ميت.

قائمة التحقق

ترك Claude يصمم schema متعدد اللغات في Rails باستخدام jsonb — 6 قواعد:

  1. استبعد 19 جدول / 19 عمود أولاً. ما لم يختلف منطق العرض جوهرياً لكل locale، jsonb هو الإجابة الوحيدة.
  2. 3 أعمدة jsonb + قيود متدرجة: title null: false, default: {}؛ summary / content فقط default: {}.
  3. default: {} هو hash حرفي، ليس سلسلة. السلسلة "{}" تُسبِّب عدم تطابق النوع.
  4. النموذج يُوفِّر accessors تدعم fallback: current → en → .values.first. الطبقات العرضية لا ترى nil أبداً.
  5. الاستعلامات في SQL: title ? 'zh' (وجود)، title->>'zh' (قيمة/ترتيب). لا تسحب إلى Ruby للتصفية.
  6. انتظر حتى يكون لديك حاجة استعلامية فعلية للفهرسة. إضافة GIN وقائياً هندسة مفرطة؛ الحجوم الصغيرة بعمليات البحث slug/id لا تحتاج فهرس jsonb.

القرار الحقيقي ليس "jsonb أم لا" — مع 19 لغة الإجابة واضحة. القرارات الحقيقية هي أي الحقول إلزامية مقابل اختيارية، كيف تعمل سلسلة fallback في النموذج، متى يُنتقل من "لا فهرس بعد" إلى "أضف فهرس تعبيري". هذه قرارات منتج وحجم بيانات. Claude يعطيك الكود لكن لن يتخذ هذه القرارات نيابةً عنك.