Ruby on Rails - 5 bước để tạo enum hoàn hảo

Chắc hẳn các lập trình viên Rails không xa lạ gì với từ khóa enum. Một model của bạn có thể chứa nhiều thuộc tính với các loại dữ liệu khác nhau. Trong một số trường hợp, thuộc tính của bạn chỉ có thể được gán cho một trong một vài giá trị đã được định nghĩa sẵn, kiểu đó được gọi là enumeration hoặc đơn giản là enum. Ví dụ như một loại của hình thức phân phối: chuyển phát nhanh, gói bưu kiện hoặc cá nhân. Rails support enum từ version 4.1

Project ví dụ được dùng trong bài viết có liên quan đến các tác phẩm nghệ thuật, Artworks được thu thập trong một Catalogs. Catalog là một trong những model lớn nhất của project và trong số các thuộc tính của bảng này, chúng ta có 4 enum:

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 một enum vào một model đã tồn tại thực sự rất đơn giản. Trước hết bạn cần thêm một migration thích hợp, chú ý kiểu dữ liệu của cột nên là integer vì đây là cách mà Rails lưu các giá trị enum 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

Bước tiếp theo là khai báo thuộc tính enum trong model:

class Catalog < ActiveRecord::Base
  enum status: [:published, :unpublished, :not_set]
end

Chạy rake migrate và thế là xong! Từ bây giờ bạn có thể tận dụng toàn bộ các phương thức hữu ích của enum.

Ví dụ: bạn có thể kiểm tra xem status hiện tại có giá trị là giá trị đặc tả hay không:

catalog.published? # false

Hoặc thay đổi giá trị khác cho status:

catalog.status = "published" # published
catalog.published! # published

Liệt kê tất cả catalog đã publish:

Catalog.published

Bạn có thể xem tất cả các phương thức của enum tại ActiveRecord::Enum

Giải pháp trên phù hợp cho dự án lúc ban đầu, nhưng bạn có thể gặp rắc rối khi dự án của bạn ngày càng phát triển. Để chuẩn bị, bạn có thể thực hiện một vài cải tiến giúp cho enums của bạn dễ bảo trì hơn:

1. Khai báo enum của bạn là một hash thay vì một array

Nhược điểm khi dùng array: Mapping giữa các giá trị được khai báo và số nguyên lưu trong database là dựa trên thứ tự trong mảng.

Ví dụ:

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 chút nào. Hãy tưởng tượng rằng các yêu cầu vừa thay đổi, bây giờ, localization foreign sẽ được chia thành "America" và "Asia". Trong trường hợp đó, bạn nên xóa giá trị cũ và thêm hai giá trị mới. Nhưng… bạn không thể xóa loại "foreign" sẽ không được sử dụng, bởi vì nó ảnh hưởng đến thứ tự của các giá trị còn lại. Để tránh tình trạng này, bạn nên khai báo enum của bạn dưới dạng Hash. Đơn giản bạn chỉ cần thay đổi:

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ự các phần tử, bạn có thể thực hiện các thay đổi hoặc loại bỏ các giá trị không sử dụng.

2. Tích hợp ActiveRecord::Enum với PostgreSQL enum

Nhược điểm của bước trước: Các giá trị số nguyên không có ý nghĩa trong cơ sở dữ liệu.

Làm việc với các thuộc tính có giá trị là các số nguyên trong database có thể gây phiền toái. Hãy tưởng tượng rằng bạn muốn truy vấn một cái gì đó trong rails console hoặc khi bạn tạo một scope dựa trên các trường enum của bạn, giả sử bạn muốn lấy tất cả các catalog vẫn đang được cập nhập, bạn có thể viết một mệnh đề where như sau:

Catalog.where.not(“state = ?”, “finished”)

Chúng ta có thể nhận lại lỗi:

ActiveRecord::StatementInvalid: PG::InvalidTextRepresentation:
ERROR: invalid input syntax for integer: "finished"

Vấn đề này xảy ra chỉ trong định dạng array của mệnh đề where vì giá trị thứ 2 được truyền trực tiếp vào mệnh đề where SQL là "finished' và đây không phải là một số nguyên.

Một trường hợp tương tự có thể xuất hiện khi bạn triển khai truy vấn SQL phức tạp mà không sử dụng ActiveRecord. Khi truy vấn không truy cập được vào model thì bạn sẽ mất thông tin có ý nghĩa về các giá trị của enum và chỉ còn lại các số nguyên thuần túy không có ý nghĩa. Trong trường hợp đó, bạn cần phải tìm cách để làm cho các số nguyên này có ý nghĩa trở lại.

Một tình huống khó chịu khác có thể xảy ra khi bạn làm việc với một cơ sở dữ liệu kế thừa như thế này. Bạn có quyền truy cập vào cơ sở dữ liệu và bạn chỉ quan tâm đến dữ liệu được lưu giữ ở đó. Bạn không thể nhận được thông tin ngay lập tức từ những gì bạn thấy và luôn cần phải mapping những con số này thành các giá trị thực tế của enum.

Bạn nên nhớ rằng khi enum integer bị tách khỏi model của nó như trong các ví dụ trên thì chúng ta sẽ mất thông tin của dữ liệu đó.

Để thuyết phục hơn, khi khai báo ActiveRecord::Enum, không có gì đảm bảo rằng dữ liệu của bạn sẽ bị hạn chế chỉ với các giá trị đã được cung cấp. Thay đổi có thể được thực hiện bởi bất kỳ lệnh SQL insert nào. Mặt khác, khi bạn khai báo PostgreSQL enum bạn sẽ bị hạn chế về mức cơ sở dữ liệu. Bạn cần phải quyết định mức độ chắc chắn của mình.

PostgreSQL thường được sử dụng như một database trong các dự án Ruby on Rails. Bạn có thể sử dụng PostgreSQL enum làm kiểu của một thuộc tính trong bảng.

Hãy xem nó trông như thế nào:

rails g migration add_status_to_catalogs status:catalog_status

Bạn cần thay đổi loại thuộc tính. Tôi không khuyên bạn nên tạo các loại dữ liệu như "status" vì có khả năng một trạng thái khác sẽ xuất hiện trong tương lai. Tiếp theo, bạn cần thay đổi file migration một chút. Nên cài đặt một hàm có thể rollback migration và có thể thực thi SQL.

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

Và khai báo giống như trước trong model:

class Catalog < ActiveRecord::Base
 enum status: { published: "published", unpublished: "unpublished", not_set: "not_set" }
end

3. Thêm index cho thuộc tính enum

Nhược điểm của bước trước: Hiệu suất truy vấn.

Việc thêm index này thì đơn giản. Thuộc tính enum của bạn có khả năng được dùng để phân biệt các đối tượng trong một model cụ thể. Giống như status của chúng ta: một số Catalog được publish và một số khác thì không. Kết quả là, tìm kiếm hoặc lọc theo thuộc tính đó sẽ là một hành động khá thường xuyên, do đó, chúng ta nên thêm một chỉ mục vào trường đó giúp cho việc tìm kiếm nhanh hơn. Hãy thêm migration để thực hiện điều đó:

class AddIndexToCatalogs < ActiveRecord::Migration
  def change
    add_index :catalogs, :status
  end
end

4. Sử dụng tiền tố(prefix) hoặc hậu tố(suffix) trong tên enum

Nhược điểm của bước trước:

  • Các scope không trực quan
  • Tính dễ đọc kém của các phương thức helper
  • Dễ bị lỗi

Hãy nhớ lại các enum trong model catalog:

state: ["incoming", "in_progress", "finished"]

auction_type: ["traditional", "live", "internet"]

status: ["published", "unpublished", "not_set"]

localization: ["home", "foreign", "none"]

Để thêm prefix hoặc suffix cho enum, thêm vào option khi khai báo enum:

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 sự hữu dụng của chúng, trong model catalog có 4 enum và 12 giá trị trong đó, nó tạo ra 12 scopes và những scope này đều không trực quan:

Catalog.not_set
Catalog.live
Catalog.unpublished
Catalog.in_progress

Liệu bạn có thể đễ dàng nhìn ra giá trị mà những phương thức này trả về? Không, bạn phải luôn nhớ các scope trông như thế nào và nó thực sự là phiền nhiễu.

Catalog.status_not_set
Catalog.live_auction_type
Catalog.status_unpublished
Catalog.state_in_progress

như thế này thì trông tốt hơn.

Giả sử bây giờ bạn cần thêm một enum nữa vào model của bạn và nó lưu thông tin về thứ tự của mỗi catalog bên trong catalog chung. Thứ tự của một số catalog có thể không được chỉ định. Điều quan trọng nhất là phải biết cái nào là cái đầu tiên và cái nào là cuối cùng. Chúng ta có thể tạo một enum khác:

class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }
end

Hãy mở rails console và test enum mới:

Catalog.order

Bạn sẽ nhận lỗi:

 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 có thể fix nó:

class Catalog < ActiveRecord::Base
  enum order: { first_catalog: "first_catalog", last_catalog: "last_catalog", other: "other", none: "none" }
end

nhưng lại nhận 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.)

Ok, giờ thì rõ ràng. Chúng ta quên rằng giá trị "none" đã được khai báo trong một thuộc tính khác.

Prefix hoặc suffix là lựa chọn hoàn hảo để tránh các rắc rối. Chúng ta có thể khai báo các giá trị giống như chúng ta muốn, không có lý do gì để thay đổi các từ mà dễ mô tả nhất. Theo cách tiếp cận đó, các scope sẽ trực quan và có ý nghĩa hơn. Như vậy, thuộc tính mới có thể khai báo như sau:

class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }, _prefix: :order
end

5. Cài đặt Value Object để xử lý một enum

Nhược điểm của bước trước: Fat model

Enum nên được tách thành value object trong hai trường hợp:

  • Thuộc tính enum được sử dụng trong nhiều model (ít nhất là 2).
  • Thuộc tính enum đặc tả một logic phức tạp trong một model.

Giờ hãy xem tình huống của chúng ta: Các nhà đấu giá của chúng ta (auction house - nơi các tác phẩm nghệ thuật được bán) được đặt trên toàn quốc. Ba Lan chia thành 16 vùng, được gọi là voivodeships. Mỗi model AuctionHouseAddress cụ thể chứa thuộc tính Voivodeship. Bạn có thể tưởng tượng rằng vì một lý do nào đó cần thiết để chỉ liệt kê những nhà đấu giá phía bắc hoặc những nơi có voivodeship phổ biến nhất. Trong trường hợp đó, nó là cần thiết để đưa thêm logic vào model của chúng ta, những gì khiến cho nó thành fat model. Để tránh điều đó, bạn có thể trích xuất logic đó vào một lớp khác mà nó có thể tái sử dụng và gọn gàng 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 của bạn, bạn cần phải ghi đè lên thuộc tính này. Trong project của chúng ta là model Address. array_to_enum_hash chỉ là phương thức helper chuyển đổi array của các giá trị enum thành một Hash.

class Address < ApplicationRecord
  enum voivodeship: array_to_enum_hash(Voivodeship::VOIVODESHIPS), _sufix: true

  def voivodeship
    @voivodeship ||= Voivodeship.new(read_attribute(:voivodeship))
  end
end

Đây là những gì bạn đạt được. Toàn bộ logic liên quan đến voivodeships được đóng gói thành một class duy nhất. Bạn có thể mở rộng nó như bạn muốn và model Address vẫn giữ nguyên.

Giờ, khi bạn muốn lấy thuộc tính voivodeship, đối tượng của lớp Voivodeship sẽ được trả về, đó chính là Value Object của bạn.

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">

Hãy xem cả hai voivodeships có cùng giá trị (voivodeship_a và voivodeship_b), nhưng vì chúng là đối tượng nên chúng không bằng nhau. chúng ta có thể kiểm tra điều đó nhờ phương thức eql?:

voivodeship_a.eql? voivodeship_b
# true

voivodeship_a.eql? voivodeship_c
# false

và chúng ta cũng có thể sử dụng được các phương thức đã mà chúng ta đã định nghĩa ra trước đó:

voivodeship_a.northern? # true
voivodeship_a.popular? # false

voivodeship_c.northern? # false
voivodeship_c.popular? # false

Ok, bạn vừa đọc qua 5 phương án cải tiến enum. Bây giờ là lúc tổng hợp tất cả các bước và tạo ra giải pháp tối ưu. Ví dụ: hãy lấy thuộc tính status từ model Catalog. Việc cài đặt có thể như sau:

  • Tạo migration:
rails g migration add_status_to_catalogs status:catalog_status
  • Chạy migrate:
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, :catalog_status
    add_index :catalogs, :status
  end

  def down
    remove_column :catalogs, :status
    execute <<-SQL
      DROP TYPE catalog_status;
    SQL
  end
end
  • Tạo ValueObject:
class CatalogStatus
  STATUSES = %w(published unpublished not_set).freeze

  def initialize(status)
    @status = status
  end

  # what you need here
end
  • Tạo catalog model 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

Tổng kết

Trên đây là 5 bước giúp cải tiến enum trong ứng dụng Rails. Một số trong số chúng có thể cần thiết, một số thì không được sử dụng nhiều lắm. Bạn có thể lựa chọn giải pháp nào tùy theo nhu cầu của mình. Hy vọng rằng bạn đã tìm thấy một cái gì đó hữu ích trong bài viết này.

Link nguồn: ruby-on-rails-enum


All Rights Reserved