Rails Model 中使用 Enum (枚舉)

1. ActiveRecord::Enum 介紹

在 Rails 的 ActiveRecord 中,有一個(gè) ActiveRecord::Enum 的 Module,即枚舉對(duì)象。

Declare an enum attribute where the values map to integers in the database, but can be queried by name.

給數(shù)據(jù)庫(kù)中的整型字段聲明一個(gè)一一對(duì)應(yīng)的枚舉屬性值,這個(gè)值可以以字面量用于查詢。

拿到具體的運(yùn)用場(chǎng)景中去考慮,Enum 的主要用于數(shù)據(jù)庫(kù)中類似于 狀態(tài)(status) 的字段,這類字段用不同的 整數(shù)(Integer) 來(lái)表示不用的狀態(tài)。如果不使用 Enum,那就意味著我們代碼中會(huì)出現(xiàn)很多表示狀態(tài)的數(shù)字,他們可能會(huì)出現(xiàn)在查詢條件里,也可能會(huì)出現(xiàn)在判斷條件里,除非你記得或者拿著數(shù)據(jù)字典去看,否則你很難理解這段代碼的含義。

代碼中,以數(shù)字方式去表示數(shù)據(jù)狀態(tài),導(dǎo)致代碼可讀性被破壞,這樣的數(shù)字被稱為『魔鬼數(shù)字』。

Enum 就是 Rails 用來(lái)消滅魔鬼數(shù)字的工具。

2. ActiveRecord::Enum 的使用

常規(guī)用法:

# migration
create_table :conversations do |t|
  t.integer :status, default: 0
end

# model
class Conversation < ActiveRecord::Base
  # 相比起使用 Hash,數(shù)組相當(dāng)于隱式指定了數(shù)字鍵值,字面量的順序就很關(guān)鍵
  enum status: [ :active, :archived ]
  # or, recommended
  enum status: {active:0, archived:1}
end

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

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

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

conversation.status = nil
conversation.status.nil? # => true
conversation.status      # Conversation.where(status: active) => nil

Conversation.statuses

提供默認(rèn)scope:

Conversation.active  # => Conversation.where(status: 0)
Conversation. archived # => Conversation.where(status: 1)

獲得一個(gè)名為 statuses 的 HashWithIndifferentAccess

Conversation.statuses  #=> { "active" => 0, "archived" => 1 }
Conversation.statuses[:active]    # => 0
Conversation.statuses["archived"]  # => 1

3. ActiveRecord::Enum 在 Rails 5.0+ 中的新特性

  • where 查詢支持直接使用字面量做查詢條件
# 在 Rails 4.1+ 中
Conversation.where(status: %i[active waiting]).to_sql
# => "SELECT `conversations`.* FROM `conversations` WHERE `conversations`.`status` IN (NULL, NULL)"

# 驚不驚喜,意不意外,這也是 4.x 版本 Enum 比較雞肋的原因,正確的你應(yīng)這樣寫
Conversation.where(status: %i[active waiting].map { |s| Conversation.statuses[s] }).to_sql
# => "SELECT `conversations`.* FROM `conversations` WHERE `conversations`.`status` IN (0, 1)"

# 好在,Rails 5.0 之后就可以這么用了
Conversation.where(status: %i[active waiting]).to_sql
# => "SELECT \"conversations\".* FROM \"conversations\" WHERE \"conversations\".\"status\" IN (0, 1)"

# BUT,當(dāng)你選擇手寫 SQL 查詢條件的時(shí)候,仍是需要自己轉(zhuǎn)義的
Conversation.where("status <> ?", Conversation.statuses[:archived])
  • conversation[:status] 與 conversation.status 返回值一致,都是字面量
conversation.status # => "active"
conversation[:status] # => "active"
Conversation.pluck(:status) # => ["active", "archived",  ...]
  • 增加了兩個(gè)可選參數(shù) _prefix 和 _suffix
在 Rails 4.1+ 的版本中,即使是不同的 enum 字段也不能有同名的值

# user.rb
  enum status: [:temporary, :active, :deleted]
  enum admin_status: [:active, :super_admin]

# rails console
irb(main):001:0> u = User.new
ArgumentError: You tried to define an enum named "admin_status" on the model "User", but this will generate a instance method "active?", which is already defined by another enum.
...
于是在 Rails 5 中,引入了 _prefix 和 _suffix 兩個(gè)選項(xiàng)來(lái)解決這個(gè)問(wèn)題,它會(huì)給對(duì)應(yīng)的 !、? 以及 scope 方法加上前/后綴以示區(qū)分

# user.rb
  enum status: [:temporary, :active, :deleted], _suffix: true
  enum admin_status: [:active, :super_admin]

# rails console
  user = User.active_status.first
  user.active_status?
  user.deleted_status!

# user.rb
  enum status: [:temporary, :active, :deleted], _suffix: :stat
  enum admin_status: [:active, :super_admin]

# rails console
  user = User.active_stat.first
  user.active_stat?
  user.deleted_stat!

4.實(shí)際使用中的一些經(jīng)驗(yàn)總結(jié)

  • 不要使用數(shù)據(jù)庫(kù)的 enum ?。。?/strong>
  • 盡量升級(jí)到 Rails 5 以上的版本
  • 對(duì) enum 字段賦值時(shí),已經(jīng)隱含了數(shù)據(jù)驗(yàn)證
  • 盡量不要使用數(shù)組來(lái)定義 enum
  • 手動(dòng)設(shè)置了 table_name 時(shí),需要警惕關(guān)聯(lián)查詢的陷阱
    注:這條是 Rails 5.x 的專屬煩惱
# post.rb
class Post < ActiveRecord::Base
  self.table_name = :articles

  has_many :comments

  enum category: { it: 0, law: 1, medical: 2 }
end

# comment.rb
class Comment < ActiveRecord::Base
    belongs_to :post, foreign_key: :article_id
end

# 正常查詢是 OK 的
Post.law.to_sql 
#=> "SELECT \"articles\".* FROM \"articles\" WHERE \"articles\".\"category\" = 1"

# 作為關(guān)聯(lián)表的查詢條件,enum 字段就無(wú)法轉(zhuǎn)義了,查詢會(huì)報(bào)錯(cuò)
Comment.joins(:post).where(articles: { category: :law }).to_sql
# => "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"articles\" ON \"articles\".\"id\" = \"comments\".\"article_id\" WHERE \"articles\".\"category\" = 'law'"

# 故意寫個(gè)錯(cuò)的查詢,看看錯(cuò)出在哪兒
Comment.joins(:post).where(post: { category: :law }).to_sql
# => "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"articles\" ON \"articles\".\"id\" = \"comments\".\"article_id\" WHERE \"post\".\"category\" = 1"

通過(guò)這個(gè)對(duì)比可以發(fā)現(xiàn),因?yàn)槭謩?dòng)設(shè)置了 table_name 時(shí),關(guān)聯(lián)表查詢需要指定真實(shí)的表名,這會(huì)導(dǎo)致 enum 字段無(wú)法被正確轉(zhuǎn)義

前方高能預(yù)警,神坑要來(lái)了!??!

猜猜這個(gè)查詢會(huì)不會(huì)報(bào)錯(cuò)?能不能查出數(shù)據(jù)?

Comment.joins(:post).where(articles: { category: [:law] })

答案是:不會(huì)報(bào)錯(cuò),會(huì)查到數(shù)據(jù),但絕不是你想要的

Comment.joins(:post).where(articles: { category: [:law] }).to_sql
# => "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"articles\" ON \"articles\".\"id\" = \"comments\".\"article_id\" WHERE \"articles\".\"category\" = 0"

where 條件里面變成了 "articles"."category" = 0, 也就是查出了條件為 { category: :it } 的數(shù)據(jù),夠驚悚吧

所以,遇到這種情況,最好自己做轉(zhuǎn)義!自己做轉(zhuǎn)義!自己做轉(zhuǎn)義!

  • 給 enum 字段添加默認(rèn)值是一個(gè)好習(xí)慣
    默認(rèn)值最好還是定義的屬性值里的第一個(gè),通常來(lái)說(shuō)是『0』
create_table :conversations do |t|
  t.integer :status, limit: 2, default: 0, null: false
end
  • 添加新屬性時(shí),最好寫一個(gè)遷移
    加屬性值的時(shí)候,并不涉及到數(shù)據(jù)庫(kù)變動(dòng),為什么要寫遷移呢?當(dāng)然這不是必須的,建議寫主要出于兩項(xiàng)考慮

檢測(cè)當(dāng)前數(shù)據(jù)庫(kù)中新加的值是否已經(jīng)被占用
更新數(shù)據(jù)庫(kù)字段的 comment
當(dāng)多個(gè)系統(tǒng)使用同一張數(shù)據(jù)表的時(shí)候,可能會(huì)出現(xiàn) A 系統(tǒng)加了一個(gè)新的狀態(tài) { deleted: 3 },B 系統(tǒng)不知道,也添加了一個(gè) { reactive: 3 } 的新?tīng)顟B(tài),等到某天其中一方發(fā)現(xiàn)問(wèn)題時(shí),線上的數(shù)據(jù)早已經(jīng)是一團(tuán)漿糊了。

所以在這種情況下,應(yīng)該寫一個(gè)遷移

class AddDeletedStatusToConversations < ActiveRecord::Migration[5.1]
  def up
    raise "The value of deleted status has already been taken." if Conversation.deleted.count.positive?
    change_column :conversations, :status, :integer, default: 0, null: false, comment: "0 - active, 1 - waiting, 2 - archived, 3 - deleted"
  end
end

雖然這并不能保證萬(wàn)無(wú)一失,但及時(shí)的修改 comment 至少還是一個(gè)好習(xí)慣,畢竟使用數(shù)據(jù)庫(kù)的可能不止是寫 Rails 的人,還有 DBA,還有數(shù)據(jù)分析師,好的 comment 給他們帶來(lái)很多便利

  • 數(shù)據(jù)庫(kù)字段不一定非得是 Integer
    可以是 boolean(個(gè)人覺(jué)得 boolean 字段已經(jīng)沒(méi)有必要使用 enum 了,畢竟語(yǔ)意已經(jīng)很明確了)
    還可以是 string,在對(duì)一些老代碼做重構(gòu)的時(shí)候,這個(gè)特性可能會(huì)很實(shí)用
  • 結(jié)合 I18n 食用,風(fēng)味更佳
    針對(duì) enum 的 i18n 方案有很多,比如

enum_i18n / human_enum / active_record-humanized_enum

這三個(gè)很相似,都是把翻譯寫在 zh-CN.activerecord.attributes.conversation.statuses 下面,只是調(diào)用方式略微有點(diǎn)不同

另外還有 @zmbacker 寫的 enum_help

它的翻譯寫在 zh-CN.enums.conversation.status 下面,相比起來(lái)更直觀一點(diǎn),而且它支持 simple_form,個(gè)人比較推薦

如果你不怕麻煩,也不想引入任何 gem,還可以利用 human_attribute_name 來(lái)實(shí)現(xiàn):

# zh-CN.yml
# zh-CN:
#   activerecord:
#     attributes:
#       conversation/status:
#         active: 當(dāng)前激活
#         waiting: 等待中
#         archived: 已歸檔

conversation.status # => "active"
Conversation.human_attribute_name("status.#{conversation.status}") # => "當(dāng)前激活"
  • enum 的字面量要注意避開(kāi) model 已有的method_names/attribute_names

參考:
https://ruby-china.org/topics/28654
https://api.rubyonrails.org/v5.1/classes/ActiveRecord/Enum.html

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容