Active Record scopes và class methods

Khi làm việc với Rails framework, hẳn bạn đã không ít lần sử dụng đến scope cũng như class method. Mình làm việc với scope và class method cũng khá nhiều, và đã từng thắc mắc rằng "Hự, 2 thằng này dùng thay cho nhau được, thế sao sinh ra làm qué gì cả 2 cái cho nó phức tạp nhể?". Tuy nhiên sau khi tìm hiểu lại thì mình thấy rằng vẫn có 1 số điểm khác biệt giữa scope và class method.

Định nghĩa một scope

Trước tiên, mình sẽ nhắc lại một chút về scope nhé. Trong Rails 3, ta có thể định nghĩa scope bằng 2 cách như sau:

class Post < ActiveRecord::Base
  scope :published, where(status: 'published')
  scope :draft, -> { where(status: 'draft') } 
end

Sự khác biệt chính giữa 2 cách trên là điều kiện của scope :published được đánh giá ngay khi class Post được load lần đầu tiên, còn điều kiện của scope :draft thì sẽ được đánh giá mỗi lần mà nó được gọi đến.

Và cách làm đầu tiên đã bị loại bỏ từ Rails 4, có nghĩa là bạn luôn phải định nghĩa scope với đối số là một đối tượng có thể gọi đến được. Điều này giúp tránh gặp phải các vấn đề khi khai báo scope sử dụng thời gian làm tham số:

class Post < ActiveRecord::Base
  scope :published_last_hour, where('published_at >= ?', 1.hour.ago)
end

Nếu khai báo như trên, mốc thời gian 1.hour.ago sẽ được đánh giá khi class được load lần đầu, và do đó không đảm bảo rằng khi scope được gọi ra giá trị trả về sẽ giống như mong muốn.

Scope cũng chỉ là class method mà thôi

Bạn có để ý rằng cách bạn gọi ra scope cũng giống như cách mà bạn gọi ra class method?? Chính xác đấy ạ. Nếu bạn chọc vào core để xem cách mà Active Record triển khai 1 scope, bạn sẽ thấy được rằng thực chất scope cũng chỉ là class method mà thôi: singleton_class.send(:define_method, name)

def scope(name, body, &block)
  unless body.respond_to?(:call)
    raise ArgumentError, "The scope body needs to be callable."
  end

  if dangerous_class_method?(name)
    raise ArgumentError, "You tried to define a scope named \"#{name}\" " \
      "on the model \"#{self.name}\", but Active Record already defined " \
      "a class method with the same name."
  end

  valid_scope_name?(name)
  extension = Module.new(&block) if block

  if body.respond_to?(:to_proc)
    singleton_class.send(:define_method, name) do |*args|
      scope = all.scoping { instance_exec(*args, &body) }
      scope = scope.extending(extension) if extension

      scope || all
    end
  else
    singleton_class.send(:define_method, name) do |*args|
      scope = all.scoping { body.call(*args) }
      scope = scope.extending(extension) if extension

      scope || all
    end
  end
end

Vậy thì sinh ra scope làm qué gì nhì??? Sao không dùng mọe luôn class method đi???

Scope có tính chất chainable

Ví dụ nhé, người dùng muốn lọc ra các bài viết theo status và sắp xếp thứ tự bài viết sao cho bài viết mới được cập nhật sẽ lên đầu danh sách. Viết thử scope nhé:

class Post < ActiveRecord::Base
  scope :by_status, -> status { where(status: status) }
  scope :recent, -> { order("posts.updated_at DESC") }
end

Và chúng ta có thể gọi ra như sau:

Post.by_status(params[:status]).recent

(ngon)

Vậy thử chuyển các scope trên thành class method nào:

class Post < ActiveRecord::Base
  def self.by_status(status)
    where(status: status)
  end
  
  def self.recent
    order("posts.updated_at DESC")
  end
end

Ố kề. Chỉ là dài hơn 1 vài dòng thôi, đoạn code của chúng ta vẫn chạy ổn.

Nhưng mình lại chỉ muốn chạy filter status chỉ khi mà params[:status] không có giá trị là nil hay blank thôi thì sao?

Post.by_status(nil).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" IS NULL  ORDER BY posts.updated_at DESC

Post.by_status('').recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" = ''  ORDER BY posts.updated_at DESC

Óe. Nhìn cái điều kiện của status kìa. Giá trị trả về hơm như mình mong muốn.

OK, với scope ta có thể xử lý như sau:

scope :by_status, -> status { where(status: status) if status.present? }

Thử lại xem:

Post.by_status(nil).recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

Post.by_status('').recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

Tuyệt vời ông mặt trời.. Thử sửa cho class method nhé:

def self.by_status(status)
    where(status: status) if status.present?
end

Trông có vẻ ổn, huh? Chạy thử nhé:

Post.by_status('').recent
# NoMethodError: undefined method `recent' for nil:NilClass

Lỗi cmnr. (khoc)

Sự khác biệt là ở đây. scope luôn trả về một ActiveRecord Relation, mà class method thì không. Để cho class method chạy đúng thì phải sửa lại như sau:

def self.by_status(status)
  if status.present?
    where(status: status)
  else
    all
  end
end

Chạy lại nhé:

Post.by_status('').recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

Ngon roài... Vậy bài học rút ra là: với các class method mà bạn muốn hoạt động như scope, hãy trả về relation để giữ tính chainable cho nó nhé. Và cố gắng đừng làm mất tính chainable của scope nhé (ví dụ dùng pluck).

Scope có thể mở rộng được

Phần này, mình sẽ lấy ví dụ là tính năng phân trang nhé.

Điều cần nhất khi triển khai phân trang cho tập kết quả là truyền vào trang mà bạn muốn lấy dữ liệu đúng không nào:

posts = Post.page(3)

Và bạn cũng có thể chỉ định rằng bạn muốn lấy ra bao nhiêu bản ghi trên 1 trang dữ liệu:

posts = Post.page(3).per(15)

Tuy nhiên, bạn lại muốn biết là với cách phân chia như thế thì bạn sẽ có bao nhiêu trang dữ liệu? Hay muốn kiểm tra là bạn đang ở trang đầu, hoặc trang cuối?

posts = Post.page(3)
posts.total_pages # => 2
posts.first_page? # => false
posts.last_page?  # => true

Bạn thấy đấy, những thứ trên chỉ có ý nghĩa khi gọi từ 1 collection đã được phân trang thôi, đúng hơm nà? Vậy khi viết scope, bạn có thể bổ sung 1 số phần mở rộng mà chỉ gọi được khi scope được gọi rồi:

scope :page, -> num {
  # Code logic xử lý offset và limit để phân trang
} do
  def per(num)
    # Thêm code ở đây nè
  end
  
  def total_pages
    # Bổ sung thêm code ở đây nữa nà
  end
  
  def first_page?
    # Lại thêm code nà
  end
  
  def last_page?
    # Code nữa nà
  end
end

Thật là mạnh mẽ và linh động biết bao (ngon). Đương nhiên, chúng ta vẫn có thể triển khai bằng class method:

module PaginationExtensions
  def per(num)
    # Thêm code nè
  end
  
  def total_pages
    # Lại thêm code nữa nà
  end
  
  def first_page?
    # Thêm code tiếp nà
  end
  
  def last_page?
    # Code tiếp nà
  end
end

def self.page(num)
  scope = # Code logic xử lý offset và limit để phân trang
  scope.extend PaginationExtensions
  scope
end

Đó, tuy là nó vẫn hoạt động như mình triển khai bằng scope nhưng mà rườm rà vãi chưởng. Vậy nên, hãy chọn cách làm nào mà bạn cảm thấy tự tin nhất nhưng hãy biết tận dụng những gì mà framework cung cấp cho bạn, đừng "reinvent the wheel" nếu không cần thiết nhé.

Tham khảo: http://blog.plataformatec.com.br/2013/02/active-record-scopes-vs-class-methods/