Khởi tạo enum một cách hoàn hảo chỉ trong 5 bước
Bài đăng này đã không được cập nhật trong 7 năm
Model chứa nhiều thuộc tính với nhiều kiểu.  Một yêu cầu phổ biến là tạo ra một thuộc tính mà có thể được gán cho một trong số một vài giá trị có sẵn. Trong lập trình, nó được gọi là enumeration hoặc ngắn gọn là enum. Hơi khó hiểu nhỉ, phần tiếp theo mình sẽ đưa ra một ví dụ để dễ hiểu hơn. Lưu ý là Rails chỉ hỗ trợ enums từ version 4.1 nhé.
Bài viết gồm 3 phần:
- Giải pháp cơ bản - giới thiệu về ActiveRecord::Enum, cơ bản nhất có thể.
- 5 bước khác nhau để cải thiện enums
- Giải pháp cuối cùng - tổng hợp lại 5 cách thành 1 giải pháp hoàn hảo
Ví dụ mình đưa ra dưới đây liên quan đến các tác phẩm nghệ thuật. Các tác phẩm nghệ thuật thuộc các Catalogs. Trong các thuộc tính của Catalogs có 4 loại enums:
state: ["incoming", "in_progress", "finished"]
auction_type: ["traditional", "live", "internet"]
status: ["published", "unpublished", "not_set"]
localization: ["home", "foreign", "none"]
Giải pháp cơ bản
Thêm enums vào một Model có sẵn vô cùng đơn giản.  Đầu tiên, cần tạo một migration. Lưu ý loại của cột phải là integer, đấy là cách Rails giữ giá trị của enums trong database.
rails g migration add_status_to_catalogs status:integer
class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def change
    add_column :catalogs, :status, :integer
  end
end
Tiếp theo ta khai báo thuộc tính enum trong Model
class Catalog < ActiveRecord::Base
  enum status: [:published, :unpublished, :not_set]
end
Chạy migration và xong, từ giờ bạn có thể dùng các giá trị của enum như một phương thức.
Ví dụ bạn có thể kiểm tra status hiện tại có được set một giá trị cụ thể không:
catalog.published? # false
hoặc sửa status thành 1 giá trị khác
catalog.status = "published" # published
catalog.published! # published
Liệt kê tất cả Catalog có status là published:
Catalog.published
Bạn có thể tham khảo thêm các phương thức tại ActiveRecord::Enum.
Đây là một giải pháp tuyệt vời để bắt đầu, nhưng sẽ gặp vài vấn đề khi dự án của bạn lớn hơn. Để chuẩn bị cho điều đó, bạn có thể thực hiện một vài cải thiện cho enums.
5 bước để cải thiện enums
Thực ra gọi là 5 bước nhưng bạn hoàn toàn có thể thực hiện riêng biệt từng bước mà không hề ảnh hưởng đến nhau.
1. Khai báo enum dưới dạng Hash , không phải mảng
Nhược điểm trước khi thay đổi: việc ghép giá trị khai báo với số nguyên lưu trong database phụ thuộc vào thứ tự sắp xếp các phần tử trong mảng.
Trong ví dụ này, việc ghép giá trị sẽ như sau:
class Catalog < ActiveRecord::Base
  enum localization: [:home, :foreign, :none]
end
0 -> home
1 -> foreign
2 -> none
Cách tiếp cận này không linh hoạt một chút nào. Thử tưởng tượng nếu có 1 yêu cầu thay đổi tách foreign thành America và Asia. Trong trường hợp này bạn phải bỏ đi giá trị cũ và thêm vào 2 giá trị mới. Tuy nhiên, bạn không thể bỏ đi foreign vì nó sẽ làm hỏng thứ tự sắp xếp các cái còn lại. Để tránh tình trạng này, bạn nên khai báo enum dưới dạng Hash. Cũng không phải thay đổi gì nhiều:
class Catalog < ActiveRecord::Base
  enum localization: { home: 0, foreign: 1, none: 2 }
end
Cách khai báo này không phụ thuộc vào thứ tự nên bạn hoàn toàn có thể thay đổi và loại bỏ giá trị enum không sử dụng.
2. Tích hợp ActiveRecord::Enum với PostgreSQL enum
Nhược điểm trước khi thay đổi: giá trị không có nghĩa trong cơ sở dữ liệu
Làm việc với các thuộc tính được đại diện bởi số nguyên trong database có thể gây phiền toái. Tưởng tượng bạn phải thực hiện 1 query trong rails console hoặc viết 1 scope dựa trên trường enum . Quay trở lại ví dụ trước, mình muốn lấy ra tất cả Catalog vẫn còn thời hạn:
Catalog.where.not(“state = ?”, “finished”)
Chúng ta sẽ gặp phải lỗi này:
ActiveRecord::StatementInvalid: PG::InvalidTextRepresentation: ERROR: invalid input syntax for integer: "finished"
Vấn đề này chỉ xảy ra khi sử dụng where, vì giá trị thứ 2 sẽ được đặt trực tiếp vào trong câu sql, mà "finished" chắc chắn không phải một số nguyên.
Trường hợp tương tự cũng có thể xảy ra khi bạn trực tiếp gọi đến 1 câu SQL phức tạp, bỏ qua ActiveRecord. Khi truy vấn không được gọi vào Model, nó sẽ không hiểu được giá trị của các số nguyên mà chỉ hiểu đó là các số nguyên bình thường. Trong trường hợp đó, bạn cần mất công để làm các số nguyên đó có ý nghĩa trở lại.
PostgreSQL thường được sử dụng như một database trong Ruby on Rails. Bạn có thể sử dụng  PostgreSQL enum như một kiểu của thuộc tính trong database.
Giờ hãy xem thử nó như thế nào.
rails g migration add_status_to_catalogs status:catalog_status
Tiếp theo chúng ta cần thay đổi migration một chút.
class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      CREATE TYPE catalog_status AS ENUM ('published', 'unpublished', 'not_set');
    SQL
    add_column :catalogs, :status, :catalogs_status
  end
  def down
    remove_column :catalogs, :status
    execute <<-SQL
      DROP TYPE catalog_status;
    SQL
  end
end
Khai báo enum thì vẫn tương tự như trước
class Catalog < ActiveRecord::Base
  enum status: { published: "published", unpublished: "unpublished", not_set: "not_set" }
end
3. Thêm index vào thuộc tính enum
Nhược điểm trước khi thay đổi: Hiệu suất truy vấn
Việc truy vấn để tìm kiếm hay lọc theo thuộc tính là khá thường xuyên, vì vậy nên thêm index vào trường đấy. Sửa đổi migration một chút:
class AddIndexToCatalogs < ActiveRecord::Migration
  def change
    add_index :catalogs, :status
  end
end
4. Sử dụng prefix hay suffix trong enum
Nhược điểm trước khi thay đổi: Các phương thức không trực quan, khó đọc hiểu, dễ lỗi
Ví dụ trong Catalog có một vài enum:
state: ["incoming", "in_progress", "finished"]
auction_type: ["traditional", "live", "internet"]
status: ["published", "unpublished", "not_set"]
localization: ["home", "foreign", "none"]
Ta thêm prefix hay suffix bằng cách:
class Catalog < ActiveRecord::Base
  enum status: { published: "published", unpublished: "unpublished", not_set: "not_set" }, _prefix: :status
  enum auction_type: { traditional: "traditional", live: "live", internet: "internet" }, _suffix: true
end
Giờ hãy xem vì sao nó lại hữu dụng. Trước kia Catalog có 4 enum với 12 giá trị. Nó sẽ tạo ra 12 scope rất không trực quan.
Catalog.not_set
Catalog.live
Catalog.unpublished
Catalog.in_progress
Nhìn vào đây bạn có dễ dàng biết được nó sẽ trả về gì không? Không, bạn sẽ phải nhớ hết 4 enum với 12 giá trị. Nó sẽ khá là phiền. Sau khi thêm prefix và suffix, nó sẽ như thế này:
Catalog.status_not_set
Catalog.live_auction_type
Catalog.status_unpublished
Catalog.state_in_progress
Dễ nhìn hơn nhiều phải không.
Giả sử giờ ta cần thêm 1 enum nữa vào Model. Nó chỉ định thứ tự sắp xếp của các Catalog. Quan trọng là Catalog đứng đầu và đứng cuối, những cái khác có thể không set một giá trị cụ thể, ta gọi nó là none.
class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }
end
Giờ hãy mở rails console để test enum mới. Chúng ta gặp phải lỗi sau:
ArgumentError: You tried to define an enum named "order" on  the model "Catalog", but this will generate a class method "first", which is already defined by Active Record.
Ok, chúng ta sẽ sửa lại nó:
class Catalog < ActiveRecord::Base
 enum order: { first_catalog: "first_catalog", last_catalog: "last_catalog", other: "other", none: "none" }
end
Lại một lỗi khác:
ArgumentError (You tried to define an enum named "order" on the model "Catalog", but this will generate an instance method "none?", which is already defined by another enum.)
Quên mất none cũng được định nghĩa trong một thuộc tính khác.
prefix và suffix là giải pháp hoàn hảo để tránh vấn đề này. Chúng ta có thể khai báo các giá trị chúng ta muốn, không việc gì phải thay đổi cả. Các scope cũng sẽ trực quan và có ý nghĩa hơn. Chúng ta sẽ sửa lại thành như này:
class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }, _prefix: :order
end
5. Triển khai Value Object để xử lý enum
Nhược điểm trước khi thay đổi: Model bị phình to
Bạn nên tách các thuộc tính enum vào các Value Object trong 2 trường hợp sau:
- Thuộc tính enumđược sử dụng trong nhiều Model (ít nhất 2).
- Thuộc tính enumcó logic cụ thể làm phức tạp Model.
Để hiểu bước này thì trước tiên bạn cần hiểu Value Object là gì.
Chúng ta sẽ tiếp tục sử dụng ví dụ về các tác phẩm nghệ thuật. Nơi bán các tác phẩm (auction houses) được đặt ở nhiều nơi. Ba Lan chia làm 16 khu vực, được gọi là voivodeships. Mỗi nơi bán tác phẩm có Address chứa thuộc tính voivodeships. Giả dụ chúng ta cần lấy ra danh sách các nơi bán tác phẩm ở phía Bắc hay ở các voivodeships phổ biến. Trong trường hợp này, việc đưa thêm logic vào model khiến nó phức tạp hơn rất nhiều. Thay vì thế ta có thể đưa logic vào một class khác để có thể tái sử dụng và dễ nhìn hơn.
class Voivodeship
  VOIVODESHIPS = %w(dolnoslaskie kujawsko-pomorskie lubelskie    lubuskie lodzkie
    malopolskie mazowieckie opolskie podkarpackie podlaskie
    pomorskie slaskie swietokrzyskie warminsko-mazurskie
    wielkopolskie zachodnio-pomorskie).freeze
  NORTHERN_VOIVODESHIPS = %w(warminsko-mazurskie pomorskie zachodnio-pomorskie podlaskie).freeze
  MOST_POPULAR_VOIVODESHIPS = %w(dolnoslaskie mazowieckie slaskie malopolskie).freeze
  def initialize(voivodeship)
    @voivodeship = voivodeship
  end
  def northern?
    NORTHERN_VOIVODESHIPS.include? @voivodeship
  end
  def popular?
    MOST_POPULAR_VOIVODESHIPS.include? @voivodeship
  end
  def eql?(other)
    to_s.eql?(other.to_s)
  end
  def to_s
    @voivodeship.to_s
  end
end
Sau đó, trong model tương ứng, bạn cần overwrite lại các thuộc tính này. Trong model Address, ta sử dụng array_to_enum_hash để chuyển mảng enum thành Hash.
class Address < ApplicationRecord
  enum voivodeship: array_to_enum_hash(Voivodeship::VOIVODESHIPS), _sufix: true
  def voivodeship
    @voivodeship ||= Voivodeship.new(read_attribute(:voivodeship))
  end
end
Tất cả logic của voivodeship đã được đóng gói thành 1 lớp. Bạn có thể mở rộng nó tùy ý mà ko làm ảnh hưởng tới model Address.
Giờ, khi bạn muốn lấy ra thuộc tính voivodeship, nó sẽ trả về đối tượng của class Voivodeship. Nó gọi là Value Object.
voivodeship_a = Address.first.voivodeship
# #<Voivodeship:0x000000000651eef0 @voivodeship="pomorskie">
voivodeship_b = Address.second.voivodeship
# #<Voivodeship:0x00000000064e9cf0 @voivodeship="pomorskie">
voivodeship_c = Address.third.voivodeship
# #<Voivodeship:0x000000000641ef00 @voivodeship="lodzkie">
voivodeship_a và voivodeship_b có giá trị giống nhau, nhưng vì nó là object, nên không thể so sánh bằng theo cách bình thường, chúng ta có thể kiểm tra bằng cách sử dụng .eql?
voivodeship_a.eql? voivodeship_b
# true
voivodeship_a.eql? voivodeship_c
# false
Giờ bạn có thể sử dụng các phương thức được định nghĩa để thực hiện yêu cầu nêu phía trên.
voivodeship_a.northern? # true
voivodeship_a.popular? # false
voivodeship_c.northern? # false
voivodeship_c.popular? # false
Giải pháp cuối cùng
Cuối cùng cũng xong 5 bước, giờ chúng ta sẽ tổng hợp lại các bước thành 1 giải pháp. Chúng ta vẫn sử dụng thuộc tính status của model Catalog.
Tạo migration:
rails g migration add_status_to_catalogs status:catalog_status
Sửa file migration:
class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      CREATE TYPE catalog_status AS ENUM ('published', 'unpublished', 'not_set');
    SQL
    add_column :catalogs, :status, :catalogs_status
    add_index :catalogs, :status
  end
  def down
    remove_column :catalogs, :status
    execute <<-SQL
      DROP TYPE catalog_status;
    SQL
  end
end
Thiết lập ValueObject:
class CatalogStatus
  STATUSES = %w(published unpublished not_set).freeze
  def initialize(status)
    @status = status
  end
  # what you need here
end
Model Catalog và khai báo enum:
class Catalog
  enum status: array_to_enum_hash(CatalogStatus::STATUSES), _sufix: true
  def status
    @status ||= CatalogStatus.new(read_attribute(:status))
  end
end
Hết rồi đó, hy vọng bài viết sẽ có ích với bạn! :3
Nguồn tham khảo: http://naturaily.com/blog/post/ruby-on-rails---how-to-create-perfect-enum-in-5-steps
All rights reserved
 
  
 