Enums trong Rails

Enums

Vậy Enums là gì? Có thể hiểu đơn giản Enums như sau:

Enums là dùng để khai báo một loại dữ liệu mới (kiểu liệt kê). Các biến thuộc kiểu Enum này chỉ được phép chứa các giá trị đã được định nghĩa từ trước.

Ví dụ

// C++
enum DaysOfWeek
{
	SUNDAY,
	MONDAY,
	TUESDAY,
	WEDNESDAY,
	THURSDAY,
	FRIDAY,
	SATURDAY
};
DaysOfWeek test1 = SUNDAY; // OK
DaysOfWeek test2 = 1; // Raise errors

Enums trong Ruby on Rails Khác với các ngôn ngữ khác, Ruby lại không hỗ trợ kiểu dữ liệu Enums. Tuy nhiên Rails lại hỗ trợ kiểu Enums cho ActiveRecord rất tốt. Chúng ta cùng xem ví dụ đơn giản sau được copy từ Rails docs

class Conversation < ActiveRecord::Base
  enum status: [ :active, :archived ]
end

# conversation.update! status: 0
conversation.active!
conversation.active? # => true
conversation.status  # => "active"

# conversation.update! status: 1
conversation.archived!
conversation.archived? # => true
conversation.status    # => "archived"

# conversation.status = 1
conversation.status = "archived"

conversation.status = nil
conversation.status.nil? # => true
conversation.status      # => nil

Với việc khai báo enum cho thuộc tính status của Model Conversation thì việc sử dụng thuộc tính này sẽ theo kiểu Enum. Thuộc tính này sẽ chỉ được phép chứa những giá trị được định nghĩa từ trước và giá trị nil. Trong ví dụ trên thì giá trị của status có thể là một trong các gía trị sau: :active, :archived, "active", "archived", 0, 1, nil

Dưới đây tôi sẽ giải thích một số method phổ biến cho kiểu dữ liệu enum trong Rails

Method Mô tả
self.status Trả về giá trị của thuộc tính status dưới dạng string (chú ý không phải là symbol hay số)
self.status= Set giá trị cho thuộc tính status. Giá trị có thể là string, symbol hoặc số nguyên trong tập hợp khi khai báo enum. Nếu set cho thuộc tính giá trị không đúng thì bạn sẽ nhận được lỗi ArgumentError.
self.active! Set giá trị cho thuộc tính status là active
self.active? Kiểm tra xem giá trị của thuộc tính status có phải là active không
Conversation.statuses Trả về một hash (different_access) với key là các giá trị status (kiểu string, symbol) còn value là giá trị integer tương ứng trong database
Conversation.active Đây là một scope lấy ra tất cả các bản ghi là active. Nó tương đương với Conversation.where(status: 0)

Chú ý: Trong ví dụ trên ta định nghĩa các giá trị cho enum là một array. Vậy giá trị tương ứng trong database của các giá trị trong Array này chính là vị trí của chúng Ví dụ: [ :active, :archived ] Như vậy :active, :archived sẽ có giá trị tương ứng là 0 và 1.

Trong trường hợp ta muốn chỉ rõ giá trị tương ứng cho từng trạng thái thì ta dùng dữ liệu kiểu Hash thay cho Array. Ví dụ {active: 1, :archived: 2} Như vậy :active, :archived sẽ có giá trị tương ứng là 1 và 2.

Queries on Enums

Điều mà bạn cần đặc biệt chú ý ở đây là đối với model query với thược tính enum trong Rails bạn phải dùng giá trị là số chứ không phải là string như thao tác với một bản ghi. Bởi vì khi bạn dùng model query thì Rails sẽ không tự động chuyển giá trị string sang giá trị số tương ứng cho bạn như với một bản ghi. Nếu bạn không chú ý điều này bạn sẽ gặp những lỗi khá nghiêm trọng. Dưới đây tôi sẽ lấy một số ví dụ:

Conversation.where("status <> ?", "archived")
or
Conversation.where.not(status: :archived)

Bạn đang mường tượng rằng 2 query trên lấy ra tất cả conversations có trạng thái không phải là archived, có gì sai đâu nhỉ? Nhưng thực sự không phải vậy. Vấn đề ở đây là enum không support cho việc sử dụng query với model (khác với thao tác trên một đối tượng). Như vậy, trong trường hợp này ActiveRecord sẽ nhận thấy thuộc tính status có kiểu dữ liệu là integer nên nó sẽ tự động chuyển parameter sang kiểu số giống với database rồi thực hiện query. Điều này giống với tất cả các trường hợp khác trong Rails.

Như vậy query trên sẽ tương tứng

Conversation.where("status <> ?", 0) # "archived".to_s.to_i => 0
Conversation.where.not(status: 0) # :archived.to_s.to_i => 0

Để giải quyết vấn đề này ta có thể dùng đến enum mapping Như vậy ta sẽ sửa 2 query trên như sau

Conversation.where("status <> ?", Conversation.statuses[:archived])
or
Conversation.where.not(status: Conversation.statuses[:archived])

Chú ý: Vấn đề này cũng xảy ra với các query khác thực hiện trên model hay active record relation như: find_or_initialize_by ...

Pluck vs Map với Enums

Qua những phần ở trên bạn cũng có thể đoán ra Pluck với Map sẽ chạy và trả ra các giá trị khác nhau. Ta xét ví dụ sau

statuses_with_map = Conversation.all.map(&:status)
statuses_with_pluck = Conversation.pluck(:status)

Trường hợp thứ nhất giá trị của statuses_with_map là là một mảng với giá trị là các string ("active", "archived"). Bởi vì mỗi một bản ghi gọi đến method status sẽ trả về kết quả là một string cho status hiện tại.

Trường hợp thứ 2 giá trị của statuses_with_pluck sẽ là một mảng với giá trị là các số nguyên giống với dữ liệu trong database. Bởi vì pluck không thao tác với đối tượng bản ghi mà thực hiện query đến cơ sở dữ liệu và trả về luôn dữ liệu mà nó nhận được.

Kết luận

Sử dụng Rails enum có nhiều ưu điểm làm cho code dễ đọc, dễ hiểu, dễ bảo trì hơn. Tuy vậy dùng enum trong Rails cũng cần phải chú ý khi gọi query đến cơ sở dữ liệu, hay sự lẫn lộn giữ giá trị hiển thị và giá trị lưu trữ trong database để tránh mắc phải những lỗi cơ bản gây hại cho hệ thống.

Cảm ơn bạn đã theo dõi bài viết !!!