本課程講解Rails 模型(Model)中基本的 CRUD 操作、模型間的關(guān)聯(lián)關(guān)系、屬性校驗、回調(diào)以及編寫 Rspec 測試的方法,并完成網(wǎng)店的數(shù)據(jù)庫模型設(shè)計。
模型(Model)是 MVC 架構(gòu)中的 M,代表數(shù)據(jù)庫,通過對模型的學(xué)習(xí),可以了解 Rails 是如何實現(xiàn)數(shù)據(jù)庫操作的。
本課時講解模型的基礎(chǔ)操作,數(shù)據(jù)遷移,常用的 CRUD 方法,在數(shù)據(jù)查詢時,如何避免 N+1問題,如何使用 scope 包裝查詢條件,編寫模型 Rspec 測試。
Active Record 模式,是由 Martin Fowler 的《企業(yè)應(yīng)用架構(gòu)模式》一書中提出的,在該模式中,一個 Active Record(簡稱 AR)對象包含了持久數(shù)據(jù)(保存在數(shù)據(jù)庫中的數(shù)據(jù))和數(shù)據(jù)操作(對數(shù)據(jù)庫里的數(shù)據(jù)進(jìn)行操作)。
對象關(guān)系映射(Object-Relational Mapping,簡稱 ORM),是將程序中的對象(Object)和關(guān)系型數(shù)據(jù)庫(Relational Database)的表之間進(jìn)行關(guān)聯(lián)。使用 ORM 可以方便的將對象的 屬性 和 關(guān)聯(lián)關(guān)系 保存入數(shù)據(jù)庫,這樣可以不必編寫復(fù)雜的 SQL 語句,而且不必?fù)?dān)心使用的是哪種數(shù)據(jù)庫,一次編寫的代碼可以應(yīng)用在 Sqlite,Mysql,PostgreSQL 等各種數(shù)據(jù)庫上。
Active Record 就是個 ORM 框架。
所以,我們可以用 Actice Record 來做這幾件事:
面向?qū)ο?/code> 的方式處理數(shù)據(jù)庫Rails 中使用了 ActiveRecord 這個 Gem,使用它可以不必去做任何配置(大多數(shù)情況是這樣的),還記得 Rails 的兩個哲學(xué)理念之一么:約定優(yōu)于配置。(另一個是 不要重復(fù)自己,這是 Dave Thomas 在《程序員修煉之道》一書里提出的。)
那么,我們講兩個 Active Record 中的約定:
比如:
| 模型(Class) | 數(shù)據(jù)表(Schema) |
|---|---|
| Post | posts |
| LineItem | line_items |
| Deer | deers |
| Mouse | mice |
| Person | people |
單詞在單復(fù)數(shù)轉(zhuǎn)換時,是按照英文語法約定的。
注:數(shù)據(jù)庫中的 Schema,指數(shù)據(jù)庫對象集合,可以被用戶直接使用。Schema 包含數(shù)據(jù)的邏輯結(jié)構(gòu),用戶可以通過命名調(diào)用數(shù)據(jù)庫對象,并且安全的管理數(shù)據(jù)庫。
在數(shù)據(jù)庫字段命名的時候,有幾個特殊意義的名字,盡量回避:
在我們使用 scaffold 創(chuàng)建資源的時候,或者使用 generate 創(chuàng)建 model 的時候,Rails 會給我們自動創(chuàng)建一個數(shù)據(jù)庫遷移文件,它在 db/migrate 中,它的前綴是時間戳,他們按照時間的先后順序排列,當(dāng)運(yùn)行數(shù)據(jù)庫遷移時,他們按照時間順序先后被執(zhí)行。
新創(chuàng)建的遷移文件,我們使用 rake db:migrate 命令執(zhí)行它(們),這里會判斷,哪個遷移文件是還沒有被執(zhí)行的。
如果我們對執(zhí)行過的遷移操作不滿意,我們可以回滾這個遷移:
rake db:rollback [1]
rake db:rollback STEP=3 [2]
[1] 回滾最近的一個遷移
[2] 回滾指定的遷移個數(shù)
回滾之后,遷移停留在回滾到的那個位置的,schema 也會更新到那個位置時的狀態(tài)。比如,我們上一次遷移執(zhí)行了5個文件,我們回滾的時候,是一個個文件回滾的,所以我們指定 STEP=5,才能把剛才遷移的5個文件回滾。
在我們開發(fā)代碼的過程中,有是會因為失誤少寫了一個字段,我們回滾之后,在遷移文件中把它加上,然后,我們 rake db:migrate 再次運(yùn)行。不過,rake db:migrate:redo [STEP=3] 直接回滾然后再次運(yùn)行遷移,這樣會方便些。
這種回滾操作適合開發(fā)過程中,出現(xiàn)了新的想法,而回滾最近連續(xù)的幾個遷移。
如果我們想回滾很久以前的某個操作,而且在那個遷移之后,我們已經(jīng)執(zhí)行了多個遷移。這時該如何處理呢?
如果在開發(fā)階段,我們干脆 rake db:drop,rake db:create,rake db:migrate。但是在生產(chǎn)環(huán)境,我們決不能這么做,這時我們要針對需求,編寫一個遷移文件:
class ChangeProductsPrice < ActiveRecord::Migration
??def change
????reversible do |dir|
??????change_table :products do |t|
????????dir.up?? { t.change :price, :string }
????????dir.down { t.change :price, :integer }
??????end
????end
??end
end
或者:
class ChangeProductsPrice < ActiveRecord::Migration
def up
change_table :products do |t|
t.change :price, :string
end
end
def down
change_table :products do |t|
t.change :price, :integer
end
end
end
up 是向前遷移到最新的,down用于回滾。
我們創(chuàng)建一個 model 的時候,會自動創(chuàng)建它的 migration 文件,我們還可以使用 rails g migration XXX的方法,添加自定義的遷移文件。如果我們的命名是 "AddXXXToYYY" 或者 "RemoveXXXFromYYY" 時,會自動為我們添加字符類型的字段,比如我為 variant 添加一個color 字段:
rails g migration AddColorToVariants color:string
它的內(nèi)容是:
class AddColorToVariants < ActiveRecord::Migration
def change
add_column :variants, :color, :string
end
end
CRUD并不是一個 Rails 的概念,它表示系統(tǒng)(業(yè)務(wù)層)和數(shù)據(jù)庫(持久層)之間的基本操作,簡單的講叫“增(C)刪(D)改(U)查(R)”。
我們已經(jīng)使用 scaffold 命令創(chuàng)建了資源:商品(product),我們現(xiàn)在使用 app/models/product.rb 來演示這些操作。
首先,我們需要讓 Product 類繼承 ActiveRecord:
class Product < ActiveRecord::Base
end
這樣,Product 類就可以操作數(shù)據(jù)庫了,是不是很簡單。
我們使用 Product 類,向數(shù)據(jù)添加一條記錄,我們先進(jìn)入 Rails 控制臺:
% rails c
Loading development environment (Rails 4.2.0)
> Product.create [1]
(0.2ms) begin transaction [2]
SQL (2.8ms) INSERT INTO "products" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2015-03-14 16:23:44.640578"], ["updated_at", "2015-03-14 16:23:44.640578"]]
(0.8ms) commit transaction [2]
=> #<Product id: 1, name: nil, description: nil, price: nil, created_at: "2015-03-14 16:23:44", updated_at: "2015-03-14 16:23:44"> [3]
這里,我貼出了完整的代碼。
[1],我們使用了 Product 的類方法 create,創(chuàng)建了一條記錄。我們還有其他的方法保存記錄。
[2],begin 和 commit ,將我們的數(shù)據(jù)保存入數(shù)據(jù)庫。如果在保存的時候出現(xiàn)錯誤,比如屬性校驗失敗,拋出異常等,不會將記錄保存到數(shù)據(jù)庫。
[3],我們拿到了一個 Product 類的實例。
除了類方法,我們還可以使用實例的 save 方法,來保存記錄到數(shù)據(jù),比如:
> product = Product.new [1]
=> #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil> [2]
> product.save [3]
(0.1ms) begin transaction [4]
SQL (0.9ms) INSERT INTO "products" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2015-03-14 16:47:26.817663"], ["updated_at", "2015-03-14 16:47:26.817663"]]
(9.3ms) commit transaction [4]
=> true [5]
[1],我們使用類方法 new,來創(chuàng)建一個實例,注意,[2] 告訴我們,這是一個沒有保存到數(shù)據(jù)庫的實例,因為它的 id 還是 nil。
[3] 我們使用實例方法 save,把這個實例,保存到數(shù)據(jù)庫。
[4] 調(diào)用 save 后,會返回執(zhí)行結(jié)果,true 或者 false。這種判斷很有用,而且也很常見,如果你現(xiàn)在打開 app/controllers/products_controller.rb 的話,可以看到這樣的判斷:
if @product.save
...
else
...
end
那么,你可能會有個疑問,使用類方法 create 保存的時候,如果失敗,會返回我們什么呢?是一個實例,還是 false?
我們使用下一章里要介紹的屬性校驗,來讓保存失敗,比如,我們讓商品的名稱必須填寫:
class Product < ActiveRecord::Base
validates :name, presence: true [1]
end
[1] validates 是校驗命令,要求 name 屬性必須填寫。
好了,我們來測試下類方法 create 會返回給我們什么:
> product = Product.create
(0.3ms) begin transaction
(0.1ms) rollback transaction
=> #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil>
2.2.0 :003 >
答案揭曉,它返回給我們一個未保存的實例,它有一個實用的方法,可以查看哪里出了錯誤:
> product.errors.full_messages
=> ["名稱不能為空字符"]
當(dāng)然,判斷一個實例是否保存成功,不必去檢查它的 errors 是否為空,有兩個方法會根據(jù) errors 是否添加,而返回實例的狀態(tài):
person = Person.new
person.invalid?
person.valid?
要留意的是,invalid? 和 valid? 都會調(diào)用實例的校驗。
我使用類方法和實例方法的稱呼,希望沒有給你造成理解的障礙,如果有些難理解,建議你先看一看 Ruby 中關(guān)于類和實例的介紹。
數(shù)據(jù)查詢,是 Rails 項目經(jīng)常要做的操作,如何拿到準(zhǔn)確的數(shù)據(jù),優(yōu)化查詢,是我們要重點關(guān)注的。
查詢時,會得到兩種結(jié)果,一個實例,或者實例的集合(Array)。如果找不到結(jié)果,也會給有兩種情況,返回 nil或空數(shù)組,或者拋出 ActiveRecord::RecordNotFound 異常。
Rails 給我們提供了這些常用的查詢方法:
| 方法名稱 | 含義 | 參數(shù) | 例子 | 找不到時 |
|---|---|---|---|---|
| find | 獲取指定主鍵對應(yīng)的對象 | 主鍵值 | Product.find(10) | 異常 |
| take | 獲取一個記錄,不考慮任何順序 | 無 | Product.take | nil |
| first | 獲取按主鍵排序得到的第一個記錄 | 無 | Product.first | nil |
| last | 獲取按主鍵排序得到的最后一個記錄 | 無 | Product.last | nil |
| find_by | 獲取滿足條件的第一個記錄 | hash | Product.find_by(name: "T恤") | nil |
表中的四個方法不會拋出異常,如果需要拋出異常,可以在他們名字后面加上 !,比如 Product.take!。
如果將上面幾個方法的參數(shù)改動,我們就會得到集合:
| 方法名稱 | 含義 | 參數(shù) | 例子 | 找不到時 |
|---|---|---|---|---|
| find | 獲取指定主鍵對應(yīng)的對象 | 主鍵值集合 | Product.find([1,2,3]) | 異常 |
| take | 獲取一個記錄,不考慮任何順序 | 個數(shù) | Product.take(2) | [] |
| first | 獲取按主鍵排序得到的第N個記錄 | 個數(shù) | Product.first(3) | [] |
| last | 獲取按主鍵排序得到的最后N個記錄 | 個數(shù) | Product.last(4) | [] |
| all | 獲取按主鍵排序得到的全部記錄 | 無 | Product.all | [] |
Rails 還提供了一個 find_by 的查詢方法,它可以接收多個查詢參數(shù),返回符合條件的第一個記錄。比如:
Product.find_by(name: 'T-Shirt', price: 59.99)
find_by 有一個常用的變形,比如:
Product.find_by_name("Hat")
Product.find_by_name_and_price("Hat", 9.99)
如果需要查詢不到結(jié)果拋出異常,可以使用 find_by!。通常,以!結(jié)尾的方法都會拋出異常,這也是一種約定。不過,直接使用 find,會查詢主索引,查詢不到直接拋出異常,所以是沒有 find! 方法的。
使用 find_by 的時候,還可以使用 sql 語句,比如:
Product.find_by("name = ?", "T")
這是一個有用的查詢,當(dāng)我們搜索多個條件,并且是 OR 關(guān)系時,可以這樣做:
User.find_by("id = ? OR login = ?", params[:id], params[:id])
這句話還可以改寫成:
User.find_by("id = :id OR login = :name", id: params[:id], name: params[:id])
或者更簡潔的:
User.find_by("id = :q OR login = :q", q: params[:id])
集合的查找,最常用的方法是 where,它可以通過多種形式查找記錄:
| 查詢形式 | 實例 |
|---|---|
| 數(shù)組(Array)查詢 | Product.where("name = ? and price = ?", "T恤", 9.99) |
| 哈希(hash)查詢 | Product.where(name: "T恤", price: 9.99) |
| Not查詢 | Product.where.not(price: 9.99) |
| 空 | Product.none |
使用 where 查詢,常見的還有模糊查詢:
Product.where("name like ?", "%a%")
查詢某個區(qū)間:
Product.where(price: 5..6)
以及上面提到的,sql 的查詢:
Product.where("color = ? OR price > ?", "red", 9)
Active Record 有多種查詢方法,以至于 Rails 手冊中單獨列出一章來講解,而且講解的很細(xì)致,如果你想靈活的掌握這些數(shù)據(jù)查詢方法,建議你經(jīng)常閱讀 Active Record Query Interface 一章,這是 中文版。
和創(chuàng)建記錄一樣,更新記錄也可以使用類方法和實力方法。
類方法是 update,比如:
Product.update(1, name: "T-Shirt", price: 23)
1 是更新目標(biāo)的 ID,如果該記錄不存在,update 會拋出 ActiveRecord::RecordNotFound 異常。
update 也可以更新多條記錄,比如:
Product.update([1, 2], [{ name: "Glove", price: 19 }, { name: "Scarf" }])
我們看看它的源代碼:
# File activerecord/lib/active_record/relation.rb, line 363
def update(id, attributes)
if id.is_a?(Array)
id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) }
else
object = find(id)
object.update(attributes)
object
end
end
如果要更新全部記錄,可以使用 update_all :
Product.update_all(price: 20)
在使用 update 更新記錄的時候,會調(diào)用 Model 的 validates(校驗) 和 callbacks(回調(diào)),保證我們寫入正確的數(shù)據(jù),這個是定義在 Model 中的方法。但是,update_all 會略過校驗和回調(diào),直接將數(shù)據(jù)寫入到數(shù)據(jù)庫中。
和 update_all 類似,update_column/update_columns 也是將數(shù)據(jù)直接寫入到數(shù)據(jù)庫,它是一個實例方法:
product = Product.first
product.update_column(:name, "")
product.update_columns(name: "", price: 0)
雖然為 product 增加了 name 非空的校驗,但是 update_column(s) 還是可以講數(shù)據(jù)寫入數(shù)據(jù)庫。
當(dāng)我們創(chuàng)建遷移文件的時候,Rails 默認(rèn)會添加兩個時間戳字段,created_at 和 updated_at。
當(dāng)我們使用 update 更新記錄時,觸發(fā) Model 的校驗和回調(diào)時,也會自動更新 updated_at 字段。但是 Model.update_all 和 model.update_column(s) 在跳過回調(diào)和校驗的同時,也不會更新 updated_at 字段。
我們也可以用 save 方法,將新的屬性保存到數(shù)據(jù)庫,這也會觸發(fā)調(diào)用和回調(diào),以及更新時間戳:
product = Product.first
product.name = "Shoes"
product.save
在我們接觸計算機(jī)英語里,表示刪除的英文有很多,這里我們用到的是 destroy, delete。
使用 delete 刪除時,會跳過回調(diào),以及關(guān)聯(lián)關(guān)系中定義的 :dependent 選項,直接從數(shù)據(jù)庫中刪除,它是一個類方法,比如:
Product.delete(1)
Product.delete([2,3,4])
當(dāng)傳入的 id 不存在的時候,它不會拋出任何異常,看下它的源碼:
# File activerecord/lib/active_record/relation.rb, line 502
def delete(id_or_array)
where(primary_key => id_or_array).delete_all
end
它使用不拋出異常的 where 方法查找記錄,然后調(diào)用 delete_all。
delete 也可以是實例方法,比如:
product = Product.first
product.delete
在有具體實例的時候,可以這樣使用,否則會產(chǎn)生 NoMethodError: undefined methoddelete' for nil:NilClass`,這在我們設(shè)計邏輯的時候要注意。
delete_all 方法和 delete 是一樣的,直接發(fā)送數(shù)據(jù)刪除的命令,看一下 api 文檔中的例子:
Post.delete_all("person_id = 5 AND (category = 'Something' OR category = 'Else')")
Post.delete_all(["person_id = ? AND (category = ? OR category = ?)", 5, 'Something', 'Else'])
Post.where(person_id: 5).where(category: ['Something', 'Else']).delete_all
destroy 方法,會觸發(fā) model 中定義的回調(diào)(before_remove, after_remove , before_destroy 和 after_destroy),保證我們正確的操作。它也可以是類方法和實例方法,用法和前面的一樣。
需要說明,delete/delete_all 和 destroy/destroy_all 都可以作用在關(guān)系查詢結(jié)果,也就是(ActiveRecord::Relation)上,刪掉查找到的記錄。
如果你不想真正從數(shù)據(jù)庫中抹掉數(shù)據(jù),而是給它一個刪除標(biāo)注,可以使用 https://github.com/radar/paranoia 這個 gem,他會給記錄一個 deleted_at 時間戳,并且使用 restore 方法把它從數(shù)據(jù)庫中恢復(fù)過來,或者使用 really_destroy! 將它真正的刪除掉。