واجهة استعلامات السجل الفعال في ريلز

من موسوعة حسوب
< Rails
مراجعة 12:43، 2 فبراير 2019 بواسطة جميل-بيلوني (نقاش | مساهمات) (تعديل التصنيفات)
اذهب إلى التنقل اذهب إلى البحث

يغطي هذا الدليل مختلف الطرائق المستعملة لجلب واستعادة البيانات من قاعدة البيانات باستعمال السجل الفعَّال. بعد قراءة هذا الدليل، ستتعلم:

  • كيفية البحث عن السجلات باستعمال توابع وشروط متعددة.
  • كيفية تحديد الترتيب، والتجميع، والخاصيات المعادة، والخاصيات الأخرى للسجلات التي عُثِر عليها.
  • كيفية استعمال التحميل الحثيث (eager loading) للتقليل من عدد استعلامات قاعدة البيانات الضرورية لجلب البيانات.
  • كيفية استعمال توابع البحث الديناميكية.
  • كيفية استعمال تسلسل التوابع (method chaining) لاستعمال توابع متعددة للسجل الفعال سويةً.
  • كيفية التحقق من تواجد سجلات محددة.
  • كيفية تنفيذ حسابات مختلفة على نماذج السجل الفعال.
  • كيفية تشغيل EXPLAIN على العلاقات.

في حال كنت معتادًا على تنفيذ استعلامات SQL لإيجاد السجلات في قاعدة البيانات، فستجد أنَّ هناك طرقًا أفضل لتنفيذ هذه الاستعلامات في ريلز. يغنيك السجل الفعال عن الحاجة لتنفيذ تعليمات SQL في معظم الحالات.

إن الأمثلة الموجودة في هذا التوثيق ستستخدم النماذج التالية:

ملاحظة: جميع النماذج التالية تستخدم الحقل id مفتاحًا رئيسيًا لها، إلّا عند تحديد عكس ذلك.

class Client < ApplicationRecord
  has_one :address
  has_many :orders
  has_and_belongs_to_many :roles
end
class Address < ApplicationRecord
  belongs_to :client
end
class Order < ApplicationRecord
  belongs_to :client, counter_cache: true
end
class Role < ApplicationRecord
  has_and_belongs_to_many :clients
end

سينفذ السجل الفعال استعلامات SQL من أجلك، وجميع هذه الاستعلامات مطابقة مع معظم أنظمة قواعد البيانات، بما فيها MySQL، و MariaDB، و PostgreSQL، و SQLite. بغض النظر عن نمط قاعدة البيانات المستخدمة، تكون توابع السجل الفعال المستخدمة في الاستعلامات ذاتها لجميع الأنظمة السابقة الذكر.

جلب الكائنات من قاعدة البيانات

لقراءة الكائنات من قاعدة البيانات، يزوّدك السجل الفعال بمجموعة من توابع البحث. يسمح كل تابع بحث بتمرير وسائط له لتنفيذ استعلامات محددة على قاعدة البيانات دون كتابة استعلامات SQL خام.

هذه التوابع هي:

  • find
  • create_with
  • distinct
  • eager_load
  • extending
  • from
  • group
  • having
  • includes
  • joins
  • left_outer_joins
  • limit
  • lock
  • none
  • offset
  • order
  • preload
  • readonly
  • references
  • reorder
  • reverse_order
  • select
  • where

إن توابع البحث التي تعيد مجموعات، مثل التابع where والتابع group، تعيد نسخةً من ActiveRecord::Relation. والتوابع التي تبحث عن كائن وحيد، مثل التابع find والتابع first، تعيد نسخةً وحيدةً من نوع النموذج المطلوب.

يمكن تلخيص العمليات الأساسية للتابع (Model.find(options كالتالي:

  • تحويل الخيارات المعطاة إلى استعلام SQL خام.
  • تنفيذ الاستعلام وقراءة النتائج الموافقة من قاعدة البيانات.
  • تهيئة كائنات روبي الموافقة للنموذج المطلوب تنفيذ الاستعلام عليه، وذلك من أجل كل حقل من الحقول الناتجة.
  • تشغيل توابع رد النداء مثل after_find ثم after_initialize، في حال وجودهما.

جلب كائن وحيد

يزوّد السجل الفعال مجموعة من الطرق المختلفة لجلب كائن وحيد.

التابع find

باستخدام التابع find، يمكنك جلب الكائن المقابل لقيمة المفتاح الرئيسي الممرر له. مثلًا:

# 10 (id) ابحث عن الزبون بالمفتاح الرئيسي
client = Client.find(10)
# => #<Client id: 10, first_name: "Ryan">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients WHERE (clients.id = 10) LIMIT 1

يرمي التابع find استثناءً من النوع ActiveRecord::RecordNotFound في حال لم يتم العثور على أي سجل مطابق. يمكنك أيضًا استخدام هذا التابع للاستعلام عن مجموعة من الكائنات، عن طريق تمرير مصفوفة من قيم المفتاح الرئيسي للتابع. تكون القيمة المعادة مصفوفة تحتوي على جميع السجلات المطابقة لقيم المفتاح الرئيسي الممررة. مثلًا:

# ابحث عن الزبائن بقيم المفتاح الرئيسي 1 و 10.
clients = Client.find([1, 10]) # Client.find(1, 10) أو يمكن استعمال
# => [#<Client id: 1, first_name: "Lifo">, #<Client id: 10, first_name: "Ryan">]

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients WHERE (clients.id IN (1,10))

تحذير: يرمي التابع find استثناءً من النوع ActiveRecord::RecordNotFound إلّا إذا تم العثور على سجل واحد على الأقل من أجل جميع قيم المفتاح الرئيسي الممررة.

التابع take

يعيد التابع take سجلًا بدون أي ترتيب واضح. مثلًا:

client = Client.take
# => #<Client id: 1, first_name: "Lifo">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients LIMIT 1

يعيد التابع take القيمة nil في حال لم يتم العثور على أي سجل، ولن يتم رمي استثناء. يمكنك تمرير وسيط رقمي للتابع take لإعادة مجموعة السجلات التي عددها يوافق الوسيط الممرر. مثلًا:

clients = Client.take(2)
# => [
#   #<Client id: 1, first_name: "Lifo">,
#   #<Client id: 220, first_name: "Sara">
# ]

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients LIMIT 2

إن التابع !take يعمل تمامًا مثل التابع take، إلا أنّه يرمي استثناء من النوع ActiveRecord::RecordNotFound في حال عدم العثور على أي سجل.

ملاحظة: قد يختلف السجل المعاد بناءً على محرك قاعدة البيانات المستخدم.

التابع first

يبحث التابع first عن أول سجل، مرتّب حسب ترتيب المفتاح الرئيسي (بشكل افتراضي). مثلًا:

client = Client.first
# => #<Client id: 1, first_name: "Lifo">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.id ASC LIMIT 1

يعيد التابع first القيمة nil في حال لم يتم العثور على أي سجل، ولن يتم رمي استثناء.

في حال احتوى النطاق الافتراضي على تابع ترتيب، سيعيد التابع first السجل الأول بناءً على الترتيب المحدد في النطاق.

يمكنك تمرير وسيط عددي للتابع first لإعادة مجموعة السجلات التي عددها يوافق الوسيط الممرر. مثلًا:

clients = Client.first(3)
# => [
#   #<Client id: 1, first_name: "Lifo">,
#   #<Client id: 2, first_name: "Fifo">,
#   #<Client id: 3, first_name: "Filo">
# ]

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.id ASC LIMIT 3

عند استخدام التابع first على مجموعة مرتبة باستخدام التابع order، سيعيد التابع first أول سجل بناءً على الترتيب المحدد في التابع order.

client = Client.order(:first_name).first
# => #<Client id: 2, first_name: "Fifo">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.first_name ASC LIMIT 1

إن التابع !first يعمل تمامًا مثل التابع first إلا أنّه يرمي استثناء من النوع ActiveRecord::RecordNotFound في حال عدم العثور على أي سجل.

التابع last

يبحث التابع last عن آخر سجل، مرتّب حسب المفتاح الرئيسي (بشكل افتراضي). مثلًا:

client = Client.last
# => #<Client id: 221, first_name: "Russel">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.id DESC LIMIT 1

يعيد التابع last القيمة nil في حال لم يتم العثور على أي سجل، ولن يتم رمي استثناء.

في حال احتوى النطاق الافتراضي على تابع ترتيب، سيعيد التابع last السجل الأول بناءً على الترتيب المحدد في النطاق.

يمكنك تمرير وسيط رقمي للتابع last لإعادة مجموعة السجلات التي عددها يوافق الوسيط الممرر. مثلًا:

clients = Client.last(3)
# => [
#   #<Client id: 219, first_name: "James">,
#   #<Client id: 220, first_name: "Sara">,
#   #<Client id: 221, first_name: "Russel">
# ]

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.id DESC LIMIT 3

عند استخدام التابع last على مجموعة مرتبة باستخدام التابع order، سيعيد التابع last آخر سجل بناءً على الترتيب المحدد في التابع order.

client = Client.order(:first_name).last
# => #<Client id: 220, first_name: "Sara">

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients ORDER BY clients.first_name DESC LIMIT 1

إن التابع !last يعمل تمامًا مثل التابع last إلا أنّه يرمي استثناء من النوع ActiveRecord::RecordNotFound في حال عدم العثور على أي سجل.

التابع find_by

يبحث التابع find_by عن أول سجل يطابق الشروط المعطاة. مثلًا:

Client.find_by first_name: 'Lifo'
# => #<Client id: 1, first_name: "Lifo">
 
Client.find_by first_name: 'Jon'
# => nil

يماثل كتابة:

Client.where(first_name: 'Lifo').take

استعلام SQL الموافق للتعليمة السابقة هو:

SELECT * FROM clients WHERE (clients.first_name = 'Lifo') LIMIT 1

إن التابع !find_by يعمل تمامًا مثل التابع find_by إلا أنّه يرمي استثناء من النوع ActiveRecord::RecordNotFound في حال عدم العثور على أي سجل. مثلًا:

Client.find_by! first_name: 'does not exist'
# => ActiveRecord::RecordNotFound

هذا يماثل كتابة:

Client.where(first_name: 'does not exist').take!

جلب مجموعة من الكائنات على دفعات

من المعتاد أن نحتاج إلى المرور على مجموعة كبيرة من السجلات، كما هو الحال عندما نريد أن نرسل خبرًا ما إلى قائمة بريدية من المستخدمين، أو عند تصدير البيانات.

قد تبدو هذه القضية سهلة:

# سيأخذ هذا حيّزًا كبيرًا من الذاكرة في حال كان الجدول كبيرًا جدًا
User.all.each do |user|
 NewsMailer.weekly(user).deliver_now
end

لكن هذا الحل يصبح غير عملي عند ازدياد حجم الجدول، بما أن التابع User.all.each يخبر السجل الفعال بضرورة تحميل كل الجدول في مرور واحد، وبناء كائن لكل سجل، ومن ثم الحفاظ على كامل مصفوفة كائنات النموذج في الذاكرة. بالطبع، عندما نملك كمًا كبيرًا من السجلات، قد يزيد حجم المجموعة عن الحجم المسموح حجزه في الذاكرة أساسًا.

يزوّد ريلز بتابعين لحل هذه المشكلة وتقسيم السجلات على دفعات مناسبة للذاكرة من أجل معالجتها. التابع الأول هو find_each الذي يسترد دفعة من السجلات ومن ثم يدفع كل سجل إلى الكتلة المعطاة بشكل إفرادي ككائن من النموذج. أمَّا التابع الثاني فهو find_in_batches الذي يسترد دفعة من السجلات ومن ثم يدفع كامل الدفعة إلى الكتلة المعطاة كمصفوفة من كائنات النموذج.

ملاحظة: يراد استعمال التوابع find_each و find_in_batches لمعالجة عدد كبير من السجلات التي لا تتسع في الذاكرة بآنٍ معًا. إذا احتجت فقط للمرور على ألف سجل مثلًا، يفضّل استخدام توابع البحث العادية.

التابع find_each

يجلب التابع find_each دفعة من السجلات ومن ثم يدفع كل سجل من هذه السجلات إلى الكلتة المعطاة. في المثال التالي، يسترد التابع find_each المستخدمين على دفعات من 1000 عنصر، ومن ثم يدفعها إلى الكتلة واحدة تلو الأخرى:

User.find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

تعاد هذه العملية، ويتم استرداد المزيد من الدفعات عند الحاجة، حتى تتم معالجة جميع السجلات. يعمل التابع find_each على أصناف النماذج، كما هو مبين أعلاه، إضافةً إلى العلاقات:

User.where(weekly_subscriber: true).find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

طالما أنَّ العلاقات لا تملك ترتيبًا معينًا، التابع find_each سيحتاج إلى فرض ترتيب معين داخليًا للمرور على العناصر.

في حال وجود ترتيب معين في المستقبل، سيعتمد السلوك على الراية config.active_record.error_on_ignored_order. في حال كانت قيمتها true، سيتم رمي الاستثناء ArgumentError، وإلّا سيتم تجاهل الترتيب وتزويد تحذير، وهو السلوك الافتراضي. يمكن تجاوز هذا السلوك عن طريق الخيار error_on_ignore:، كما هو مشروح أدناه.

خيارات التابع find_each
batch_size:

يمكّنك هذا الخيار من تحديد عدد السجلات المراد جلبها في كل دفعة، قبل دفعها بشكل إفرادي إلى الكتلة. مثلًا، لقراءة السجلات على دفعات من 5000 عنصر:

User.find_each(batch_size: 5000) do |user|
  NewsMailer.weekly(user).deliver_now
end
start:

بشكل افتراضي، يتم استرداد السجلات بترتيب تصاعدي بناءً على المفتاح الرئيسي، الذي يجب أن يكون قيمة صحيحة. يمكّنك الخيار start: من تحديد أول معرّف من السلسلة عندما لا يكون أصغر معرّف هو المعرّف المطلوب. قد يكون هذا مفيدًا على سبيل المثال عندما تريد إعادة بدء معالجة دفعة محددة تمّت مقاطعتها، بفرّض أنّك تملك آخر معرّف تمّت معالجته كنقطة تحقق.

مثلًا، لإرسال رسائل بريدية لمستخدمي القائمة البريدية التي تبدأ معرّفاتهم بالقيمة 2000:

User.find_each(start: 2000) do |user|
  NewsMailer.weekly(user).deliver_now
end
finish:

يشبه هذا الخيار الخيار start:، إذ يمكنك من تهيئة آخر معرف من السلسلة عندما لا يكون أعلى معرّف هو المعرّف المطلوب. قد يكون هذا مفيدًا على سبيل المثال في حال أردت معالجة دفعة بيانات محددة بناءً على مجموعة جزئية من السجلات. يمكن تنفيذ هذ ا عبر الخيارين start: و finish:.

مثلًا، لإرسال رسائل بريدية لمستخدمي القائمة البريدية التي تكون معرّفاتهم بين 2000 و 10000:

User.find_each(start: 2000, finish: 10000) do |user|
  NewsMailer.weekly(user).deliver_now
end

مثال آخر عن استخدام هذين الخيارين معًا هو في حال أردت من مجموعة من العمليات العاملة (workers) أن تعالج نفس رتل المعالجة. يمكنك تحديد 10000 سجل لكل عملية عاملة عن طريق تحديد الخيارين start: و finish: لكل عملية.

error_on_ignore:

يتجاوز تهيئة التطبيق الافتراضية ويحدّد فيما إذا وجب رمي استثناء عند وجود ترتيب معين على العلاقة.

التابع find_in_batches

إن التابع find_in_batches مماثل جدًا للتابع find_each، بما أن كليهما يجلبان دفعات من السجلات. يقع موقع الاختلاف في طريقة دفع الكائنات إلى الكتلة المعطاة، إذ يدفع التابع find_in_batches دفعات من الكائنات إلى الكتلة كمصفوفة كائنات النموذج، بدلًا من الطريقة الإفرادية. يبيّن المثال التالي طريقة استخدام التابع عند دفع مصفوفة من 1000 فاتورة على حدة إلى الكتلة المعطاة، مع احتواء الكتلة الأخيرة على الفواتير المتبقية:

# بـ 1000 فاتورة بآن معًا add_invoices يزوّد التابع
Invoice.find_in_batches do |invoices|
 export.add_invoices(invoices)
end

يعمل التابع find_in_batches على أصناف النماذج، كما هو مبين أعلاه، إضافةً إلى العلاقات:

Invoice.pending.find_in_batches do |invoices|
  pending_invoices_export.add_invoices(invoices)
end

طالما لا تحتاج العلاقات إلى ترتيب معين، سيحتاج التابع إلى فرض ترتيب معين داخليًا للمرور على العناصر.

خيارات التابع find_in_batches

يقبل التابع find_in_batches نفس الخيارات التي يقبلها التابع find_each.

الشروط

يمكّنك التابع where من تحديد شروط معيّنة لحدّ عدد السجلات المعادة، والذي يمثل القسم WHERE في استعلام SQL. يمكن تحديد الشروط إمّا كسلسلة نصية، أو مصفوفة، أو جدول hash.

شروط السلاسل النصية

في حال أردت إضافة شروط إلى بحثك، يمكنك تحديدها مباشرةً، مثل ("'Client.where("orders_count = '2. سيبحث هذا عن جميع الزبائن الذين يملكون القيمة 2 للحقل orders_count.

تحذير: إن بناء الشروط عن طريق السلاسل النصية قد يجعل تطبيقك عرضةً لهجمات حقن الاستعلامات (SQL Injection). مثلًا، الاستعلام التالي غير آمن:

Client.where("first_name LIKE '%#{params[:first_name]}%'")

أكمل قراءة القسم التالي من أجل التعرف على الطريقة المفضلة لمعالجة الشروط باستخدام المصفوفات.

الشروط المصفوفية

ماذا لو احتجت إلى تغيير الرقم السابق مثلًا ليكون وسيطًا من مكان آخر؟ سيأخذ تابع البحث الشكل التالي:

Client.where("orders_count = ?", params[:orders])

يأخذ السجل الفعال السلسلة النصية من الشروط المطلوبة وسيطًا أولًا له، وأيّة وسطاء إضافية ستحل مكان المحرف ? في السلسلة المعطاة. في حال أردت تحديد مجموعة من الشروط:

Client.where("orders_count = ? AND locked = ?", params[:orders], false)

في هذا المثال، سيتم استبدال أول إشارة استفهام بالقيمة [params[:orders، والثانية بالقيمة false. إن التعليمة التالية هي المفضلة بشدة:

Client.where("orders_count = ?", params[:orders])

عن استعمال التعليمة التالية:

Client.where("orders_count = #{params[:orders]}")

بسبب أمان الوسائط.

سيؤدي وضع المتحول مباشرةً في سلسلة الشروط إلى تمرير قيمة هذا المتحول إلى قاعدة البيانات كما هي. هذا يعني أنّه سيكون متحول غير معالج مباشر من المستخدم الذي قد يملك نوايا خبيثة. إذا فعلت ذلك، قد تجعل كامل قاعدة بياناتك بخطر لأنه في حال عرف أحد المستخدمين أنّه يمكنه اختراق قاعدة بياناتك، فهذا سيجعله قابلًا لفعل أي شيء بها. لا تقم أبدًا بوضع متحولاتك مباشرةً في سلسلة شروط نصية.

ملاحظة: للمزيد من المعلومات حول مخاطر حقن الاستعلامات، اقرأ دليل الأمان.

الشروط النائبة

بشكل مماثل لطريقة استبدال إشارة الاستفهام بالوسائط، يمكنك أيضًا تحديد مفاتيح في سلسلة الشروط النصية مع مفاتيح/قيم الكائن Hash الممرر:

Client.where("created_at >= :start_date AND created_at <= :end_date",
  {start_date: params[:start_date], end_date: params[:end_date]})

سيعزّز هذا سهولة قراءة التعليمات بوجود مجموعة كبيرة من الشروط المتغيرة.

شروط الكائن Hash

يمكّنك السجل الفعال من تمرير شروط على شكل كائن Hash والتي تزيد من قابلية قراءة نمط كتابة الشروط. باستخدام شروط Hash، يمكنك تمرير Hash بالمفاتيح التي تمثل الحقول التي تريد التحقق منها، والقيم التي تحدد القيم المطلوبة لهذه الحقول:

ملاحظة: يمكن فقط استخدام تحقق المساواة، والمجال، والمجموعة الجزئية في شروط Hash.

شروط المساواة

Client.where(locked: true)

سيولّد هذا استعلام SQL التالي:

SELECT * FROM clients WHERE (clients.locked = 1)

يمكن أن يكون اسم الحقل أيضًا سلسلة نصية:

Client.where('locked' => true)

في حالة ارتباط الانتماء (belongs_to)، يمكن استخدام مفتاح الارتباط لتحديد فيما إذا كان من المراد استخدام كائن السجل الفعال كالقيمة الممررة. يعمل هذا التابع مع العلاقات متعددة الأشكال أيضًا.

Article.where(author: author)
Author.joins(:articles).where(articles: { author: author })

شروط المجال

Client.where(created_at: (Time.now.midnight - 1.day)..Time.now.midnight)

سيبحث هذا عن الزبائن الذين تم إنشاؤهم البارحة باستخدام استعلام BETWEEN.

SELECT * FROM clients WHERE (clients.created_at BETWEEN '2008-12-21 00:00:00' AND '2008-12-22 00:00:00')

يمثّل هذا استخدامًا أبسط من الشروط المصفوفية.

شروط المجموعات الجزئية

إذا أردت البحث عن السجلات باستخدام تعليمة IN، يمكنك تمرير مصفوفة للكائن Hash الممثل للشرط:

Client.where(orders_count: [1,3,5])

سيولّد هذا استعلام SQL التالي:

SELECT * FROM clients WHERE (clients.orders_count IN (1,3,5))

شروط النفي NOT

يمكن بناء شروط النفي باستخدام where.not:

Client.where.not(locked: true)

كتعبير آخر، يمكن توليد هذا الاستعلام عن طريق استدعاء التابع where بدون وسائط، ومن ثم سلسلته مع التابع not وتمرير شروط where. سيولّد هذا استعلام SQL التالي:

SELECT * FROM clients WHERE (clients.locked != 1)

شروط OR

يمكن استخدام شروط OR بين علاقتين عن طريق استدعاء التابع Or على أول علاقة، ومن ثم تمرير الثانية كوسيط لهذا التابع.

Client.where(locked: true).or(Client.where(orders_count: [1,3,5]))
SELECT * FROM clients WHERE (clients.locked = 1 OR clients.orders_count IN (1,3,5))

الترتيب

لجلب السجلات من قاعدة البيانات بترتيب معين، يمكنك استخدام التابع order.

مثلًا، لاسترداد مجموعة من السجلات المرتّبة تصاعديًا حسب الحقل created_at في الجدول، جرب ما يلي:

Client.order(:created_at)
# OR
Client.order("created_at")

يمكنك أيضًا تحديد فيما إذا كان التحديد تصاعدي (ASC) أو تنازلي (DESC):

Client.order(created_at: :desc)
# OR
Client.order(created_at: :asc)
# OR
Client.order("created_at DESC")
# OR
Client.order("created_at ASC")

أو الترتيب عن طريق مجموعة من الحقول:

Client.order(orders_count: :asc, created_at: :desc)
# OR
Client.order(:orders_count, created_at: :desc)
# OR
Client.order("orders_count ASC, created_at DESC")
# OR
Client.order("orders_count ASC", "created_at DESC")

في حال أردت استدعاء الترتيب عدة مرات، يمكنك سلسلة مجموعة الاستدعاءات كالتالي:

Client.order("orders_count ASC").order("created_at DESC")
# SELECT * FROM clients ORDER BY orders_count ASC, created_at DESC

تحذير: عند استخدامك الإصدار 5.7.5 وما فوقه من MySQL، فعند اختيارك للحقول باستخدام التوابع مثل select و pluck و ids، سيرمي التابع order استثناءً من النوع ActiveRecord::StatementInvalid إلّا إذا كانت الحقول المستخدمة للترتيب مضمّنة في قائمة الحقول المختارة. اطلع على القسم التالي من أجل اختيار الحقول في مجموعة النتائج.

اختيار حقول محددة

افتراضيًا، يختار التابع Model.find جميع الحقول في مجموعة النتائج عن طريق استخدام * select. لاختيار مجموعة جزئية فقط من الحقول، يمكنك تحديدها باستخدام التابع select.

مثلًا، لاختيار الحقول viewable_by و locked:

Client.select("viewable_by, locked")

استعلام SQL المستخدم من قبل تعليمة البحث هذه ستبدو كالتالي:

SELECT viewable_by, locked FROM clients

كن حذرًا لأن هذا يعني أنّك ستهيّء كائنًا بالحقول المختارة فقط. في حال حاولت الوصول لحقول غير مختارة، فستحصل على الخطأ التالي:

ActiveModel::MissingAttributeError: missing attribute: <attribute>

حيث تكون <attribute> هو الخاصية المختارة. لن يرمِ التابع id أي استثناء، لذا من المفضل أن تكون حذرًا عند التعامل مع الارتباطات، لأنها تحتاج من التابع id أن يعمل بشكل صحيح. في حال أردت التقاط سجل وحيد من أجل كل قيمة فريدة في حقل معين، يمكنك استخدام distinct:

Client.select(:name).distinct

ستولّد التعليمة السابقة استعلام SQL التالي:

SELECT DISTINCT name FROM clients

يمكنك أيضًا حذف قيد الفردية:

query = Client.select(:name).distinct
# => يعيد الأسماء الفريدة

query.distinct(false)
# => يعيد جميع الأسماء، حتى في وجود التكرارات

الحد وتجاوز السجلات

لتطبيق تعليمة الحد LIMIT على استعلام SQL المنفذ من قبل التابع Model.find، يمكنك تحديده باستخدام التابعين limit و offset على العلاقة.

يمكنك استخدام التابع limit لتحديد عدد السجلات المراد جلبها، والتابع offset لتحديد عدد السجلات المراد تجاوزها قبل البدء بإعادة السجلات. مثلًا:

Client.limit(5)

سيعيد 5 زبائن كحد أقصى. بنا أنه لا يتجاوز السجلات، فسيعيد أول 5 سجلات من الجدول. سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM clients LIMIT 5

وبإضافة التابع offset:

Client.limit(5).offset(30)

سيعاد 5 زبائن بشكل أعظمي بدءًا من الزبون ذي الرقم 31. سيبدو استعلام SQL كالتالي:

SELECT * FROM clients LIMIT 5 OFFSET 30

التجميع

لتطبيق تعليمة التجميع GROUP BY على استعلام SQL المنفذ من توابع البحث، يمكنك استخدام التابع group.

مثلًا، إذا أردت البحث عن مجموعة التواريخ التي أُنشئت فيها الطلبات:

Order.select("date(created_at) as ordered_date, sum(price) as total_price").group("date(created_at)")

وهذا سيعطيك كائنًا وحيدًا من النوع Order من أجل كل تاريخ أنشئت فيه طلبات في قاعدة البيانات. سيبدو استعلام SQL كالتالي:

SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)

مجموع العناصر المجمعة

للحصول على مجموع العناصر المجمعة في استعلام وحيد، يمكنك استدعاء التابع count بعد التابع group.

Order.group(:status).count
# => { 'awaiting_approval' => 7, 'paid' => 12 }

سيبدو استعلام SQL المنفذ كالتالي:

SELECT COUNT (*) AS count_all, status AS status
FROM "orders"
GROUP BY status

تعليمة Having

تستخدم SQL التعليمة HAVING لوضع شروط على الحقول المجمعة باستخدام GROUP BY. يمكنك إضافة هذه الشروط في بنية HAVING للتابع Model.find عن طريق إضافة التابع having إلى تابع البحث. إليك مثال عن ذلك:

Order.select("date(created_at) as ordered_date, sum(price) as total_price").
  group("date(created_at)").having("sum(price) > ?", 100)

سيبدو استعلام SQL المنفذ كالتالي:

SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)
HAVING sum(price) > 100

سيعيد هذا التاريخ ومجموع أسعار كل كائن طلب، بعد تجميعهم عن طريق تاريخ إنشاء الطلب، حيث يكون مجموع أسعار الطلبات أكثر من 100$.

تجاوز الشروط

التابع unscope

يمكنك تحديد شروط معينة لتتم إزالتها باستخدام التابع unscope. مثلًا:

Article.where('id > 10').limit(20).order('id asc').unscope(:order)

سيبدو استعلام SQL كالتالي:

SELECT * FROM articles WHERE id > 10 LIMIT 20

# unscope الاستعلام الأساسي دون استدعاء
SELECT * FROM articles WHERE id > 10 ORDER BY id asc LIMIT 20

يمكنك أيضًا تجاوز بنى شروط where أيضًا. مثلًا:

Article.where(id: 10, trashed: false).unscope(where: :id)
# SELECT "articles".* FROM "articles" WHERE trashed = 0

العلاقة التي تستخدم التابع unscope تأثر أيضًا على أي علاقة مدمجة معها:

Article.order('id asc').merge(Article.unscope(:order))
# SELECT "articles".* FROM "articles"

التابع only

يمكنك أيضًا تجاوز الشروط عن طريق التابع only. مثلًا:

Article.where('id > 10').limit(20).order('id desc').only(:order, :where)

سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM articles WHERE id > 10 ORDER BY id DESC

# الاستعلام الأصلي دون التابع only
SELECT * FROM articles WHERE id > 10 ORDER BY id DESC LIMIT 20

التابع reorder

يتجاوز التابع reorder الترتيب المجالي الافتراضي. مثلًا:

class Article < ApplicationRecord
  has_many :comments, -> { order('posted_at DESC') }
end
 
Article.find(10).comments.reorder('name')

سيبدو استعلام SQL كالتالي:

SELECT * FROM articles WHERE id = 10 LIMIT 1
SELECT * FROM comments WHERE article_id = 10 ORDER BY name

في حال عدم استخدام التابع reorder، سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM articles WHERE id = 10 LIMIT 1
SELECT * FROM comments WHERE article_id = 10 ORDER BY posted_at DESC

التابع reverse_order

يعكس التابع reverse_order الترتيب المحدد سابقًا.

Client.where("orders_count > 10").order(:name).reverse_order

سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM clients WHERE orders_count > 10 ORDER BY name DESC

في حال عدم تحديد ترتيب سابقًا، يعكس التابع reverse_order ترتيب السجلات حسب حقل المفتاح الرئيسي.

Client.where("orders_count > 10").reverse_order

سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM clients WHERE orders_count > 10 ORDER BY clients.id DESC

لا يقبل هذا التابع أيّة وسائط.

التابع rewhere

يتجاوز التابع rewhere شروط where موجودة مسبقًا ومسمّاة. مثلًا:

Article.where(trashed: true).rewhere(trashed: false)

سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM articles WHERE `trashed` = 0

في حال عدم استخدام التابع rewhere:

Article.where(trashed: true).where(trashed: false)

سيبدو استعلام SQL المنفذ كالتالي:

SELECT * FROM articles WHERE `trashed` = 1 AND `trashed` = 0

العلاقة الفارغة (Null Relation)

يعيد التابع none علاقة قابلة للسلسلة بدون سجلات، إذ عند سلسلة المزيد من الشروط للعلاقة المعادة، سيتم توليد المزيد من العلاقات الفارغة. يفيد هذا في الحالات التي تحتاج بها إلى رد قابل للسلسلة لتابع أو نطاق يمكن أن لا يعيد سجلات إطلاقًا.

Article.none # يعيد علاقة فارغة ولا ينفذ أي استعلامات
# أدناه من المتوقع أن يعيد علاقة visible_articles التابع
@articles = current_user.visible_articles.where(name: params[:name])
 
def visible_articles
  case role
  when 'Country Manager'
    Article.where(country: country)
  when 'Reviewer'
    Article.published
  when 'Bad User'
    Article.none # => returning [] or nil breaks the caller code in this case
  end
end

كائنات القراءة فقط

يزوّد السجل الفعال بالتابع readonly في العلاقات لتحديد عدم إمكانية تعديل أي من الكائنات المعادة بشكل ظاهري. لن تنجح أي عملية لتعديل سجلات القراءة فقط، الأمر الذي يرمي استنثناءً من النوع ActiveRecord::ReadOnlyRecord.

client = Client.readonly.first
client.visits += 1
client.save

بما أن الكائن client محدد ليكون قابلًا للقراءة فقط، سترمي التعليمات السابقة استثناءً من النوع ActiveRecord::ReadOnlyRecord عند استدعاء التابع client.save بالقيمة المحدثة للحقل visits.

منع تعديل السجلات

إن عملية القفل هي عملية مفيدة لتجنب حالات التسابق (أو حالات التعارض - Race Conditions) عند تحديث السجلات في قاعدة البيانات.

يزوّد السجل الفعال بطريقتان للقفل:

  • القفل المستبشر (Optimistic Locking).
  • القفل المستطير (Pessimistic Locking).

القفل المستبشر

يمكّن القفل المستبشر مجموعة من المستخدمين الوصول إلى السجل ذاته من أجل التعديل، ويفرض أن عددًا أصغريًا من التعارضات تحصل في البيانات. يقوم بذلك عن طريق التحقق فيما إذا قامت عملية أخرى بتعديلات على السجل منذ فتحه. سيرمى الاستثناء ActiveRecord::StaleObjectError في حال حصل ذلك، وسيتم تجاهل التحديث.

حقل القفل المستبشر

لاستخدام القفل المستبشر، يجب على الجدول أن يحوي حقلًا يسمى lock_version من نوع القيمة الصحيحة (integer). عند تحديث سجل، يقوم السجل الفعال بزيادة قيمة الحقل lock_version. في حال طلب تحديث بقيمة lock_version أصغر من القيمة الموجودة حاليًا في الحقل lock_version في قاعدة البيانات، سيفشل التعديل وسيرمى الاستثناء ActiveRecord::StaleObjectError. مثلًا:

c1 = Client.find(1)
c2 = Client.find(1)
 
c1.first_name = "Michael"
c1.save
 
c2.name = "should fail"
c2.save # Raises an ActiveRecord::StaleObjectError

ستكون مسؤولًا بالتالي عن التحقق من التضاربات، سواءً عن طريق المرور التعاودي في الاستثناء أو عن طريق الاستعادة أو الدمج أو تطبيق منطق عمل مختلف لإصلاح التضارب.

يمكن تعطيل هذا السلوط عن طريق تعيين الخيار ActiveRecord::Base.lock_optimistically للقيمة false.

لتجاوز اسم الحقل lcok_version، يزوّد الصنف ActiveRecord::Base خاصية تدعى locking_column:

class Client < ApplicationRecord
  self.locking_column = :lock_client_column
end

القفل المستطير

يستخدم القفل غير الفعال طريقة قفل مزودة من قبل طبقة قاعدة البيانات. باستخدام التابع lock عند بناء علاقة، سيتم بناء قفل حصري (exclusive lock) على السجلات المحددة. إن العلاقات التي تستخدم التابع lock تكون محاطة بعملية (transaction) معظم الأوقات، وذلك لتجنب حالات القفل الميت (deadlock).

مثلًا:

Item.transaction do
  i = Item.lock.first
  i.name = 'Jones'
  i.save!
end

الجلسة السابقة تولّد استعلام SQL التالي من أجل قاعدة بيانات MySQL:

SQL (0.2ms)   BEGIN
Item Load (0.3ms)   SELECT * FROM `items` LIMIT 1 FOR UPDATE
Item Update (0.4ms)   UPDATE `items` SET `updated_at` = '2009-02-07 18:05:56', `name` = 'Jones' WHERE `id` = 1
SQL (0.8ms)   COMMIT

يمكنك أيضًا تمرير تعليمات SQL خام للتابع lock من أجل السماح بأنواع مختلفة من الأقفال. مثلًا، تملك MySQL تعبيرًا يسمّى LOCK IN SHARE MODE، حيث يمكنك فيه قفل سجل، والسماح بالاستعلامات الأخرى أن تقرأه. لتحديد هذا التعبير، مرّره كخيار للتابع lock:

Item.transaction do
  i = Item.lock("LOCK IN SHARE MODE").find(1)
  i.increment!(:views)
end

في حال كان لديك كائنًا من النموذج، يمكنك بدء عملية (transaction) والحصول على القفل باستخدام تعليمة واحدة كالتالي:

item = Item.first
item.with_lock do
 # ستتم استدعاء هذه الكتلة ضمن العملية
 # والعنصر يكون مقفولًا
 item.increment!(:views)
end

دمج الجداول

يزوّد السجل الفعال بتابعي بحث لتحديد تعليمات الدمج (التعليمة JOIN) على استعلام SQL المنفذ: التابع joins والتابع left_outer_joins. في حين أن التابع joins يجب استخدامه من أجل الدمج الداخلي (INNER JOIN) أو الاستعلامات المخصصة، يستخدم التابع left_outer_joins للاستعلامات التي تستخدم الدمج الخارجي (LEFT OUTER JOIN).

التابع joins

هناك عدة طرائق لاستخدام التابع joins.

باستخدام قطعة SQL كسلسلة نصية

يمكنك تحديد تعليمة SQL خام لتحديد بنية الدمج JOIN كسلسلة نصية، وتمريرها للتابع joins:

Author.joins("INNER JOIN posts ON posts.author_id = authors.id AND posts.published = 't'")

سيولّد هذا استعلام SQL التالي:

SELECT authors.* FROM authors INNER JOIN posts ON posts.author_id = authors.id AND posts.published = 't'

باستخدام مصفوفة/كائن Hash من الارتباطات المسماة

يمكّنك السجل الفعال من استخدام أسماء الارتباطات المعرّفة على النموذج كطريق قصير لتحديد بنية تعليمة الدمج لهذه الارتباطات باستخدام التابع joins.

مثلًا، لنفرض النماذج التالية:

class Category < ApplicationRecord
  has_many :articles
end
 
class Article < ApplicationRecord
  belongs_to :category
  has_many :comments
  has_many :tags
end
 
class Comment < ApplicationRecord
  belongs_to :article
  has_one :guest
end
 
class Guest < ApplicationRecord
  belongs_to :comment
end
 
class Tag < ApplicationRecord
  belongs_to :article
end

والآن، ستولّد جميع الطرق التالية الدمج الداخلي المتوقع.

دمج ارتباط وحيد
Category.joins(:articles)

سيولّد ذلك:

SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id

أو، باللغة الفصيحة: "أعد الكائن Category من أجل جميع الفئات (categories) التي تملك مقالات (articles)". والآن عند رؤيتك لقيم مكررة فهذا يعني أنها تملك أكثر من مقالة واحدة لنفس الفئة. في حال أردت الأصناف الفريدة فقط، يمكنك استخدام:

Category.joins(:articles).distinct

دمج مجموعة ارتباطات

Article.joins(:category, :comments)

سيولّد ذلك:

SELECT articles.* FROM articles
  INNER JOIN categories ON categories.id = articles.category_id
  INNER JOIN comments ON comments.article_id = articles.id

أو، باللغة الفصيحة: "أعد جميع المقالات (articles) التي تملك فئةً (category)، وتعليقًا (comment) واحدًا على الأقل". لاحظ أنه قد تجد مقالات مكررة مجددًا.

دمج الارتباطات المتداخلة (مستوى واحد)
Article.joins(comments: :guest)

سيولّد ذلك:

SELECT articles.* FROM articles
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id

أو، باللغة الفصيحة: "أعد جميع المقالات التي تملك تعليقًا مكتوبًا من قبل زائر (guest)".

دمج الارتباطات المتداخلة (مستويات متعددة)
Category.joins(articles: [{ comments: :guest }, :tags])

سيولّد ذلك:

SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id
  INNER JOIN tags ON tags.article_id = articles.id

أو، باللغة الفصيحة: "أعد جميع الأصناف التي تملك مقالات تحتوي بدورها على تعليق مكتوب من قبل زائر، وتحتوي أيضًا على وسم (tag)".

تحديد الشروط على الجداول المدمجة

يمكنك تحديد شروط على الجداول المدمجة باستخدام الشروط المصفوفية أو النصية المعتادة. يمكن الحصول على نمط خاص لكتابة الشروط للجداول المدمجة باستخدام كائنات Hash:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Client.joins(:orders).where('orders.created_at' => time_range)

يمكن دمج شروط hash بطريقة أكثر سلاسة وأسهل للقراءة:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Client.joins(:orders).where(orders: { created_at: time_range })

سيبحث ذلك عن كل الزبائن الذين يملكون طلبات أنشئت البارحة، مجدًدا باستخدام تعليمة BETWEEN الخاصة بـ SQL.

التابع left_outer_joins

في حال أردت اختيار مجموعة من السجلات بغض النظر عن امتلاكها لسجلات مرتبطة، يمكنك استخدام التابع left_outer_joins.

Author.left_outer_joins(:posts).distinct.select('authors.*, COUNT(posts.*) AS posts_count').group('authors.id')

سيولّد ذلك:

SELECT DISTINCT authors.*, COUNT(posts.*) AS posts_count FROM "authors"
LEFT OUTER JOIN posts ON posts.author_id = authors.id GROUP BY authors.id

الذي يعني: "أعد جميع الكتّاب (authors) مع عدد المنشورات (posts) الخاصة بهم، بغض النظر عن امتلاكهم أو عدم امتلاكهم للمنشورات".

التحميل الحثيث للارتباطات

إن التحميل الحثيث (Eager loading) هو طريقة من طرائق تحميل ارتباطات السجلات المعادة من التابع Model.find، والتي تستخدم أقل عدد ممكن من الاستعلامات.

مشكلة الـ N + 1 استعلام

لنفرض التعليمات التالية التي تبحث عن 10 زبائن وتطبع عناوينهم البريدية:

clients = Client.limit(10)
 
clients.each do |client|
  puts client.address.postcode
end

قد تبدو هذه التعليمات صحيحة من النظرة الأولى، لكن المشكلة تكمن بعدد الاستعلامات المنفذة. ستنفّذ التعليمات السابقة استعلامًا واحدًا من أجل تحميل 10 زبائن، و10 استعلامات من أجل كل زبون لتحميل عنوانه، مما ينفذ 11 استعلام ككل.

الحل لمشكلة الـ N + 1 استعلام

يمكّنك السجل الفعال من تحديد الارتباطات المراد تحميلها بشكل مسبق. يمكن هذا عن طريق تحديد التابع includes لاستدعاء Model.find. باستخدام includes، سيتأكد السجل الفعال من أن جميع الارتباطات قد تم تحميلها باستخدام أقل عدد من الاستعلامات.

بالعودة للتعليمات السابقة، يمكننا كتابة التالي لتحميل العناوين بشكل حثيث:

clients = Client.includes(:address).limit(10)
 
clients.each do |client|
  puts client.address.postcode
end

ستولّد التعليمات السابقة استعلامين فقط، بدلًا من 11 استعلام في الحالة السابقة، وهذه الاستعلامات هي:

SELECT * FROM clients LIMIT 10
SELECT addresses.* FROM addresses
  WHERE (addresses.client_id IN (1,2,3,4,5,6,7,8,9,10))

التحميل الحثيث لمجموعة من الارتباطات

يمكّنك السجل الفعال من تحميل مجموعة من الارتباطات بشكل حثيث ضمن استدعاء Model.find وحيد، عن طريق استخدام مصفوفة، كائن Hash، أو كائن Hash متداخل مع مصفوفات باستخدام التابع includes.

مصفوفة ارتباطات

Article.includes(:category, :comments)

سيحمّل هذا جميع المقالات والفئات المرتبطة بها والتعليقات لكل مقال.

كائنات Hash المتداخلة للارتباطات

Category.includes(articles: [{ comments: :guest }, :tags]).find(1)

سيبحث هذا عن الفئة ذات المعرف1 وسيقوم بتحميل حثيث لجميع المقالات المرتبطة، وجميع وسوم المقالات المرتبطة، وتعليقات الزوار المرتبطة أيضًا.

فرض شروط على التحميل الحثيث

في حين أن السجل الفعال يمكّنك من فرض شروط على الارتباطات المحملة بشكل حثيث كما هو الحال في التابع joins، إلّا أنّه من المفضّل استخدام التابع joins بدلًا من ذلك.

لكن إذا أردت استخدام هذه الطريقة، يمكنك استخدام التابع where كما هو الحال طبيعيًا.

Article.includes(:comments).where(comments: { visible: true })

سيولّد هذا استعلامًا يحوي LEFT OUTER JOIN، في حين أنّه يقوم التابع joins بتوليد استعلام يحوي INNER JOIN بدلًا من ذلك.

SELECT "articles"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id" WHERE (comments.visible = 1)
If

في حال عدم وجود شرط where، سيولّد هذا مجموعة عادية من استعلامين. ملاحظة: إن استخدام where، فسيعمل فقط عند تمريرك للكائن Hash. من أجل قطع SQL، يجب أن تستخدم references لفرض دمج الجداول:

Article.includes(:comments).where("comments.visible = true").references(:comments)

في حال استخدام هذه الطريقة من التحميل، وعند عدم وجود تعليقات لأي من المقالات، سيتم مع ذلك تحميل جميع المقالات. باستخدام joins (أي INNER JOINتجب مطابقة شرط الدمج، وإلّا لن تعاد أيّة سجلات.

ملاحظة: في حال تمّ تحميل ارتباط بشكل حثيث كجزء من عملية دمج، لن تجد أي حقول مستخدمة في بنية اختيار مخصصة على النماذج المحملة. هذا لتجنب تضارب الحقول بين السجلات الآباء والأبناء.

النطاقات

تمكّنك النطاقات (Scopes) من تحديد استعلامات مستخدمة غالبًا والتي يمكن الرجوع لها كاستدعاءات توابع على الكائنات المرتبطة أو النماذج. مع هذه النطاقات، يمكنك استخدام جميع التوابع المذكورة مسبقًا مثل where و joins و includes. تعيد جميع توابع النطاقات الكائن ActiveRecord::Relation والذي يمكّن التوابع الأخرى (مثل النطاقات الأخرى) من أن يتم استدعاؤها على العلاقة نفسها.

لتعريف نطاق بسيط، يمكنك استخدام التابع scope داخل الصنف، وتمرير الاستعلام المستخدم عند استدعاء النطاق:

class Article < ApplicationRecord
  scope :published, -> { where(published: true) }
end

يطابق هذا تعريف تابع في الصنف، والطريقة المستخدمة هي حسب تفضيلك الشخصي:

class Article < ApplicationRecord
  def self.published
    where(published: true)
  end
end

يمكن أيضًا سلسلة النطاقات داخل النطاقات:

class Article < ApplicationRecord
  scope :published,               -> { where(published: true) }
  scope :published_and_commented, -> { published.where("comments_count > 0") }
end

لاستدعاء النطاق published، يمكننا استدعاؤها إمّا على الصنف:

Article.published # => [published articles]

أو على ارتباط يحوي على الكائن Article:

category = Category.first
category.articles.published # => [published articles belonging to this category]

تمرير الوسائط

يمكن للنطاقات أن تأخذ وسائطًا مثل:

class Article < ApplicationRecord
  scope :created_before, ->(time) { where("created_at < ?", time) }
end

ومن ثم استدعاؤها كما لو كانت تابعًا في الصنف:

Article.created_before(Time.zone.now)

لكن هذا قد يكرر الوظيفية المزودة من تعريف تابع الصنف.

class Article < ApplicationRecord
  def self.created_before(time)
    where("created_at < ?", time)
  end
end

إن استخدام تابع الصنف هو الطريقة المفضلة لإنشاء النطاقات التي تقبل وسائط ممررة لها. لا يزال بالإمكان استدعاء هذه التوابع من الكائنات المرتبطة:

category.articles.created_before(time)

استخدام الشروط

يمكن لنطاقك أن يستخدم شروطًا معينة:

class Article < ApplicationRecord
  scope :created_before, ->(time) { where("created_at < ?", time) if time.present? }
end

كما هو الحال في الأمثلة السابقة، سيعمل هذا بشكل مطابق لتوابع الصنف:

class Article < ApplicationRecord
  def self.created_before(time)
    where("created_at < ?", time) if time.present?
  end
end

لكن هناك اختلاف وحيد: سيعيد النطاق كائنًا من النوع ActiveRecord::Relation بشكل دائم، حتى لو لم يحقّق الشرط الموجود، في حين أن تابع الصنف سيعيد nil عند عدم تحقيق الشرط. قد يسبب هذا بالخطأ NoMethodError عند سلسلة (chaining) توابع الصنف مع الشروط، وذلك عندما يكون أحد الشروط false.

تطبيق نطاق افتراضي

إذا أردنا تطبيق نطاق بشكل دائم على كل الاستعلامات المستخدمة في النموذج، يمكننا تحديد ذلك في التابع default_scope ضمن النموذج نفسه:

class Client < ApplicationRecord
  default_scope { where("removed_at IS NULL") }
end

عند تنفيذ الاستعلامات على هذا النموذج، سيبدو استعلام SQL المنفذ الآن كالتالي:

SELECT * FROM clients WHERE removed_at IS NULL

في حال أردنا فعل أشياء أكثر تعقيدًا ضمن النطاق الافتراضي، يمكننا تعريفه تابعًا في الصنف:

class Client < ApplicationRecord
  def self.default_scope
    # ActiveRecord::Relation يجب أن يعيد
  end
end

ملاحظة: يطبق النطاق الافتراضي default_scope أيضًا أثناء إنشاء أو بناء سجل عند تمرير وسائط النطاق ككائن Hash. ولن يطبق عند تحديث السجل، مثلًا:

class Client < ApplicationRecord
  default_scope { where(active: true) }
end
 
Client.new          # => #<Client id: nil, active: true>
Client.unscoped.new # => #<Client id: nil, active: nil>

لاحظ أنه عند استخدام الشكل المصفوفي، لن يكون من الممكن تحويل وسائط الاستعلامات إلى كائن Hash من أجل الإسناد الافتراضي للحقول. مثلًا:

class Client < ApplicationRecord
  default_scope { where("active = ?", true) }
end
 
Client.new # => #<Client id: nil, active: nil>

دمج النطاقات

كما هو الحال في where، يمكن دمج النطاقات باستخدام شروط AND.

class User < ApplicationRecord
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end
 
User.active.inactive
# SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'inactive'

يمكننا دمج ومطابقة التابع scope مع التابع where، وسيحوي الاستعلام الأخير على جميع الشروط المدموجة بواسطة AND.

User.active.where(state: 'finished')
# SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'finished'

في حال أردنا من آخر تعليمة where أن تتحقق وتُطبَّق، يمكن استخدام Relation.merge.

User.active.merge(User.inactive)
# SELECT "users".* FROM "users" WHERE "users"."state" = 'inactive'

الاختلاف الوحيد المهم هو أن النطاق الافتراضي سيتم إلحاقه افتراضيًا في شروط scope و where.

class User < ApplicationRecord
  default_scope { where state: 'pending' }
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end
 
User.all
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending'
 
User.active
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending' AND "users"."state" = 'active'
 
User.where(state: 'inactive')
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending' AND "users"."state" = 'inactive'

كما ترى أعلاه، يتم دمج النطاق الافتراضي في شرطي scope و where.

إزالة جميع النطاقات

في حال أردنا إزالة جميع النطاقات لأي سبب كائن، يمكننا استخدام التابع unscope. يكون هذا مفيدًا عندما يتم تحديد نطاق افتراضي على نموذج ما، ولا نريد تطبيقه على استعلام ما.

Client.unscoped.load

يقوم هذا بحذف جميع النطاقات وتنفيذ استعلام عادي على الجدول.

Client.unscoped.all
# SELECT "clients".* FROM "clients"
 
Client.where(published: false).unscoped.all
# SELECT "clients".* FROM "clients"

يقبل للتابع unscoped هيكلًا من التعليمات.

Client.unscoped {
  Client.created_before(Time.zone.now)
}

توابع البحث الديناميكية

من أجل كل حقل (أو خاصية) تعرّفه في جدولك، يزوّدك السجل الفعال بتابع بحث. إذا ملكت حقلًا مسمى first_name على النموذج Client مثلًا، ستحصل على التابع find_by_first_name بالمجان من السجل الفعال. وإذا ملكت حقلًا مسمى locked على النموذج Client، ستحصل أيضًا على التابع find_by_locked.

يمكنك أيضًا تحديد إشارة التعجب في نهاية توابع البحث الديناميكية لتجعلها ترمي الاستثناء ActiveRecord::RecordNotFound في حال لم تعد أيّة سجلات، مثلًا:

Client.find_by_name!("Ryan")

وفي حال أردت البحث عن طريق الحقلين name و locked معًا، يمكنك إمّا سلسلة هذه التوابع معًا، أو كتابة "and" بين أسماء الحقلين. مثلًا:

Client.find_by_first_name_and_locked("Ryan", true)

الماكرو enums

يعين الماكرو enum حقلًا عدديًّا صحيحًا لمجموعة من القيم الممكنة.

class Book < ApplicationRecord
  enum availability: [:available, :unavailable]
end

سينشئ هذا النطاقات الموافقة على النموذج تلقائيًا. تضاف أيضًا التوابع للتنقل بين الحالات الممكنة والاستعلام عن الحالة الحالية للنموذج.

# يبحث المثلان التاليان عن الكتب المتوافرة
Book.available
# أو
Book.where(availability: :available)
 
book = Book.new(availability: :available)
book.available?   # => true
book.unavailable! # => true
book.available?   # => false

اقرأ التوثيق الكامل حول الماكرو enums لمزيد من التفاصيل.

فهم سَلسَلة التوابع

يطبّق نمط السجل الفعال مفهوم سَلْسَلة (Chaining) التوابع، الذي يمكننا من استخدام مجموعة من توابع السجل الفعال معًا في طريقة سَلِسَلةٍ وبسيطةٍ.

يمكنك سَلسَلة التوابع في تعليمة واحدة عندما يعيد التابع السابق علاقة (كائنًا من نوع ActiveRecord::Relation)، مثل التوابع all و where و joins. التوابع التي تعيد كائنًا وحيدًا يجب أن تكون في نهاية التعليمة.

هناك بعض الأمثلة أدناه، ولكن لا يغطي هذا التوثيق جميع الحالات المحتملة بل يتطرق إلى كم صغير منها فقط. عند استدعاء تابع في السجل الفعال، لن يتم تنفيذ الاستعلام وإرساله إلى قاعدة البيانات، إذ سيحصل ذلك فقط عند الحاجة للبيانات. لذلك كل مثال من الأمثلة التالية سيولّد فقط استعلامًا واحدًا.

جلب بيانات مرشَّحة من مجموعة جداول

Person
  .select('people.id, people.name, comments.text')
  .joins(:comments)
  .where('comments.created_at > ?', 1.week.ago)

سيبدو الناتج كالتالي:

SELECT people.id, people.name, comments.text
FROM people
INNER JOIN comments
  ON comments.person_id = people.id
WHERE comments.created_at > '2015-01-01'

جلب بيانات محددة من مجموعة جداول

Person
  .select('people.id, people.name, companies.name')
  .joins(:company)
  .find_by('people.name' => 'John') # this should be the last

سيولّد ذلك الاستعلام التالي:

SELECT people.id, people.name, companies.name
FROM people
INNER JOIN companies
  ON companies.person_id = people.id
WHERE people.name = 'John'
LIMIT 1

ملاحظة: في حال عثر الاستعلام على مجموعة من السجلات، سيقرأ التابع find_by أول سجل فقط وسيتجاهل السجلات الأخرى.

البحث عن كائن أو بناؤه

من الضروري أحيانًا البحث عن سجل أو بناؤه في حال عدم وجوده. يمكنك تحقيق ذلك باستخدام التوابع find_or_create_by أو !find_or_create_by.

التابع find_or_create_by

يتحقق التابع find_or_create_by من أن سجلًا ما موجود بالحقول المعطاة. وفي حال عدم وجوده، سيتم استدعاء التابع create.

مثلًا، في حال أردت البحث عن زبون يدعى Sara، وفي حال عدم وجوده يجب إنشاؤه. يمكنك تحقيق ذلك عن طريق تنفيذ التالي:

Client.find_or_create_by(first_name: 'Sara')
# => #<Client id: 1, first_name: "Sara", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">

سيبدو استعلام SQL كالتالي:

SELECT * FROM clients WHERE (clients.first_name = 'Sara') LIMIT 1
BEGIN
INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Sara', 1, NULL, '2011-08-30 05:22:57')
COMMIT

يعيد التابع find_or_create_by إما السجل الموجود مسبقًا، أو السجل الجديد. في حالتنا، كنا لا نملك زبونًا يدعى Sara، لذا تم إنشاء السجل وإعادته مباشرةً.

قد لا يتم حفظ السجل في قاعدة البيانات، إذ يعتمد ذلك على مرور السجل للتأكيدات الموجودة أو لا (كما هو الحال في create).

لنفرض أننا بحاجة لتعيين الحقل locked ليصبح false عند إنشائنا لسجل جديد، لكننا لا نحتاج لتضمينه في الاستعلام. لذا سنحتاج للبحث عن الزبون المسمى Sara، وفي حال عدم وجود هذا الزبون، إنشاء زبون باسم "Sara"، وتعيين الحقل locked إلى false.

يمكننا تحقيق ذلك بطريقتين، الأولى باستخدام create_with:

Client.create_with(locked: false).find_or_create_by(first_name: 'Sara')

والثانية باستخدام كتلة معطاة:

Client.find_or_create_by(first_name: 'Andy') do |c|
  c.locked = false
end

سيتم تنفيذ الكتلة فقط عند إنشاء الزبون، إذ أنه في المرة الثانية من تنفيذ هذه التعليمة، سيتم تجاهل الكتلة.

التابع !find_or_create_by

يمكنك أيضًا استخدام التابع !find_or_create_by لرمي استثناء في حال كان السجل الجديد خطأ. لن تتم تغطية التحققات من الصحة في هذا التوثيق، لكننا لنفرض أنك قمت بإضافة التحقق التالي:

validates :orders_count, presence: true

في نموذج الزبون Client. والآن عند محاولتك لإنشاء زبون دون تمرير الحقل orders_count، سيكون السجل خطأ وسيتم رمي استثناء:

Client.find_or_create_by!(first_name: 'Andy')
# => ActiveRecord::RecordInvalid: Validation failed: Orders count can't be blank

التابع find_or_initialize_by

يعمل التابع find_or_initialize_by تمامًا مثل التابع find_or_create_by، لكنه سيستدعي التابع new بدلًا من create. مما يعني أنه لن يتم حفظ الكائن في قاعدة البيانات، وإنما فقط في الذاكرة. باستكمال المثال الموضح أعلاه، نحتاج الآن إلى إنشاء الزبون المسمى Fadi:

fadi = Client.find_or_initialize_by(first_name: 'Fadi')
# => #<Client id: nil, first_name: "Fadi", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">
 
fadi.persisted?
# => false
 
fadi.new_record?
# => true

لأن الكائن لم يتم حفظه بعد في قاعدة البيانات، سيبدو استعلام SQL المولّد كالتالي:

SELECT * FROM clients WHERE (clients.first_name = 'Fadi') LIMIT 1

عندما تريد حفظه في قاعدة البيانات، استدعِ التابع save:

fadi.save
# => true

البحث بواسطة SQL

في حال أردت استخدام تعليمات SQL الخاصة بك للبحث عن سجلات في جدول ما، يمكنك استخدام التابع find_by_sql. يعيد هذا التابع مصفوفة من الكائنات حتى لو كانت مجموعة النتائج تحوي كائنًا وحيدًا. مثلًا، يمكنك تنفيذ الاستعلام التالي:

Client.find_by_sql("SELECT * FROM clients
  INNER JOIN orders ON clients.id = orders.client_id
  ORDER BY clients.created_at desc")
# =>  [
#   #<Client id: 1, first_name: "Lucas" >,
#   #<Client id: 2, first_name: "Jan" >,
#   ...
# ]

يزوّدك التابع find_by_sql بطريقة بسيطة لإنشاء استدعاءات مخصصة لقاعدة البيانات واسترداد الكائنات المهيّئة.

التابع select_all

يملك التابع find_by_sql تابعًا قريبًا منه يسمى connection.select_all. يسترد هذا التابع الكائنات من قاعدة البيانات بواسطة SQL مخصص كما هو الحال في التابع find_by_sql، لكنه لن يهيّء هذه الكائنات. إذ سيعيد كائنًا من نوع ActiveRecord::Result، وعند استدعاء to_hash على هذا الكائن، سيتم تحويله إلى مصفوفة من الكائنات Hash حيث كل جدول Hash يمثل سجلًا.

Client.connection.select_all("SELECT first_name, created_at FROM clients WHERE id = '1'").to_hash
# => [
#   {"first_name"=>"Rafael", "created_at"=>"2012-11-10 23:23:45.281189"},
#   {"first_name"=>"Eileen", "created_at"=>"2013-12-09 11:22:35.221282"}
# ]

التابع pluck

يمكن استخدام التابع pluck للاستعلام عن حقل وحيد أو مجموعة حقول من الجدول الموافق للنموذج. يقبل هذا التابع قائمة بأسماء الحقول كوسيط له، ويعيد مصفوفة القيم بالحقول المحددة وأنواع بياناتها.

Client.where(active: true).pluck(:id)
# SELECT id FROM clients WHERE active = 1
# => [1, 2, 3]
 
Client.distinct.pluck(:role)
# SELECT DISTINCT role FROM clients
# => ['admin', 'member', 'guest']
 
Client.pluck(:id, :name)
# SELECT clients.id, clients.name FROM clients
# => [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]

يجعل التابع pluck من الممكن أن يستبدل بتعليمات مثل:

Client.select(:id).map { |c| c.id }
# أو
Client.select(:id).map(&:id)
# أو
Client.select(:id, :name).map { |c| [c.id, c.name] }

التعليمات التالية:

Client.pluck(:id)
# أو
Client.pluck(:id, :name)

على نقيض التابع select، سيحول التابع pluck نتيجة قاعدة البيانات مباشرةً إلى مصفوفة، دون الحاجة لتهيئة كائنات السجل الفعال. يؤدي ذلك إلى تحسين الأداء من أجل استعلام ضخم ومنفذ بشكل متردد. لكن، لن تكون التوابع المتجاوزة في النموذج متاحةً. مثلًا:

class Client < ApplicationRecord
  def name
    "I am #{super}"
  end
end
 
Client.select(:name).map &:name
# => ["I am David", "I am Jeremy", "I am Jose"]
 
Client.pluck(:name)
# => ["David", "Jeremy", "Jose"]

أيضًا على نقيض sleect أو نطاقات العلاقة Relation، ينفذ التابع pluck استعلامًا مباشرًا، وبالتالي لا يمكن سَلسَلته بنطاقات لاحقة، لكنه يعمل بنطاقات مطبقة سابقًا:

Client.pluck(:name).limit(1)
# => NoMethodError: undefined method `limit' for #<Array:0x007ff34d3ad6d8>
 
Client.limit(1).pluck(:name)
# => ["David"]

التابع ids

يستخدم التابع ids لتحديد فقط المعرفات الخاصة بالعلاقات باستخدام المفتاح الرئيسي للجدول.

Person.ids
# SELECT id FROM people
class Person < ApplicationRecord
  self.primary_key = "person_id"
end
 
Person.ids
# SELECT person_id FROM people

وجود الكائنات

في حال أردت التحقق من وجود الكائنات، يوجد تابع يسمى ?exists. يقوم هذا التابع بتنفيذ استعلام على قاعدة البيانات باستخدام نفس استعلام التابع find، لكن بدلًا من إعادة الكائن ككل، يعيد القيمة true أو false.

Client.exists?(1)

يأخذ التابع ?exists مجموعة قيم، لكن الاختلاف أنه سيعيد true في حال وجود أي من هذه السجلات.

Client.exists?(id: [1,2,3])
# أو
Client.exists?(name: ['John', 'Sergei'])

يمكن أيضًا استخدام التابع ?exists دون تمرير الوسائط على النموذج أو العلاقة.

Client.where(first_name: 'Ryan').exists?

تعيد التعليمة السابقة القيمة true في حال كان هناك زبون واحد على الأقل بالاسم Ryan، والقيمة false فيما عدا ذلك.

Client.exists?

تعيد التلعيمة السابقة القيمة false في حال كان الجدول clients فارغًا، والقيمة true فيما عدا ذلك. يمكنك أيضًا استخدام التوابع ?any و ?many للتحقق من وجود نموذج أو علاقة.

# عبر نموذج
Article.any?
Article.many?
 
# عبر مجال مسمى
Article.recent.any?
Article.recent.many?
 
# عبر علاقة
Article.where(published: true).any?
Article.where(published: true).many?
 
# عبر ارتباط
Article.first.categories.any?
Article.first.categories.many?

الحسابات

يستخدم هذا القسم التابع count كتابع مثال عن هذا المفهوم، لكن الخيارات المشروحة يمكن استخدامها لجميع الأقسام الفرعية.

تعمل جميع توابع الحساب على النموذج مباشرةً:

Client.count
# SELECT COUNT(*) FROM clients

أو على علاقة:

Client.where(first_name: 'Ryan').count
# SELECT COUNT(*) FROM clients WHERE (first_name = 'Ryan')

يمكنك أيضًا استخدام توابع البحث المتعددة على علاقة لتنفيذ عمليات حساب معقدة:

Client.includes("orders").where(first_name: 'Ryan', orders: { status: 'received' }).count

والذي سينفذ:

SELECT COUNT(DISTINCT clients.id) FROM clients
  LEFT OUTER JOIN orders ON orders.client_id = clients.id
  WHERE (clients.first_name = 'Ryan' AND orders.status = 'received')

التابع count

في حال أردت معرفة عدد السجلات الموجودة في جدول النموذج، يمكنك استدعاء Client.count. في حال أردت أن تكون أكثر دقة، وأردت البحث عن الزبائن الذين يملكون أعمارًا في قاعدة البيانات، يمكنك استخدام (Client.count(:age.

التابع average

في حال أردت معرفة معدل رقم معين في جدول من جداولك، يمكنك استدعاء التابع average مع الصنف المتعلق بالجدول. يعمل الاستدعاء هذا كالتالي:

Client.average("orders_count")

سيعيد ذلك عددًا (غالبًا عددًا عشريًا مثل 3.14159265) الذي يمثل معدل قيم هذا الحقل.

التابع minimum

في حال أردت معرفة أصغر قيمة موجودة في حقل من حقول جدولك، يمكنك استدعاء التابع minumum على الصنف المتعلق بالجدول. يعمل استدعاء التابع الكتالي:

Client.minimum("age")

التابع maximum

في حال أردت معرفة أكبر قيمة موجودة في حقل من حقول جدولك، يمكنك استدعاء التابع maximum على الصنف المتعلق بالجدول. يعمل استدعاء التابع الكتالي:

Client.maximum("age")

التابع sum

في حال أردت معرفة مجموع عناصر حقل مين في جدولك، يمكنك استدعاء التابع sum على الصنف المتعلق بالجدول. يعمل استدعاء التابع كالتالي:

Client.sum("orders_count")

تنفيذ تعليمة EXPLAIN

يمكنك تنفيذ تعليمة EXPLAIN على الاستعلامات المولّدة من العلاقات، مثلًا:

User.where(id: 1).joins(:articles).explain

قد يولّد:

EXPLAIN for: SELECT `users`.* FROM `users` INNER JOIN `articles` ON `articles`.`user_id` = `users`.`id` WHERE `users`.`id` = 1
+----+-------------+----------+-------+---------------+
| id | select_type | table    | type  | possible_keys |
+----+-------------+----------+-------+---------------+
|  1 | SIMPLE      | users    | const | PRIMARY       |
|  1 | SIMPLE      | articles | ALL   | NULL          |
+----+-------------+----------+-------+---------------+
+---------+---------+-------+------+-------------+
| key     | key_len | ref   | rows | Extra       |
+---------+---------+-------+------+-------------+
| PRIMARY | 4       | const |    1 |             |
| NULL    | NULL    | NULL  |    1 | Using where |
+---------+---------+-------+------+-------------+
 
2 rows in set (0.00 sec)

عند استخدام قواعد البيانات MySQL و MariaDB. ينفّذ السجل الفعال طباعة جميلة لمحاكاة طباعة سطر أوامر قاعدة البيانات. لذا من أجل نفس الاستعلام وعند استخدام قاعدة بيانات PostgreSQL، ستولّد تعليمة EXPLAIN التالي:

EXPLAIN for: SELECT "users".* FROM "users" INNER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "users"."id" = 1
                                  QUERY PLAN
------------------------------------------------------------------------------
 Nested Loop Left Join  (cost=0.00..37.24 rows=8 width=0)
   Join Filter: (articles.user_id = users.id)
   ->  Index Scan using users_pkey on users  (cost=0.00..8.27 rows=1 width=4)
         Index Cond: (id = 1)
   ->  Seq Scan on articles  (cost=0.00..28.88 rows=8 width=4)
         Filter: (articles.user_id = 1)
(6 rows)

قد يولّد التحميل الحثيث أكثر من استعلام في الخلفية، وقد تحتاج بعض الاستعلامات نتائج الاستعلامات السابقة. بسبب ذلك، ينفذ التابع explain الاستعلام أولًا، ومن ثم يطلب خطة الاستعلام. مثلًا:

User.where(id: 1).includes(:articles).explain

يولّد:

EXPLAIN for: SELECT `users`.* FROM `users`  WHERE `users`.`id` = 1
+----+-------------+-------+-------+---------------+
| id | select_type | table | type  | possible_keys |
+----+-------------+-------+-------+---------------+
|  1 | SIMPLE      | users | const | PRIMARY       |
+----+-------------+-------+-------+---------------+
+---------+---------+-------+------+-------+
| key     | key_len | ref   | rows | Extra |
+---------+---------+-------+------+-------+
| PRIMARY | 4       | const |    1 |       |
+---------+---------+-------+------+-------+
 
1 row in set (0.00 sec)
 
EXPLAIN for: SELECT `articles`.* FROM `articles`  WHERE `articles`.`user_id` IN (1)
+----+-------------+----------+------+---------------+
| id | select_type | table    | type | possible_keys |
+----+-------------+----------+------+---------------+
|  1 | SIMPLE      | articles | ALL  | NULL          |
+----+-------------+----------+------+---------------+
+------+---------+------+------+-------------+
| key  | key_len | ref  | rows | Extra       |
+------+---------+------+------+-------------+
| NULL | NULL    | NULL |    1 | Using where |
+------+---------+------+------+-------------+
 
 
1 row in set (0.00 sec)

عند استخدام قواعد البيانات MySQL و MariaDB.

فهم خرج تعليمة EXPLAIN

إن فهم خرج تعليمة EXPLAIN هو خارج حدود هذا التوثيق. قد تفيدك مراجع أخرى في ذلك:

مصادر