本課時講解模型在數(shù)據(jù)查詢時,如何避免 N+1問題,使用 scope 包裝查詢條件,編寫模型 Rspec 測試。
ActiveRecord 這個 gem 中,包含了兩個重要的 gem,打開它的 源代碼,可以看到這兩個 gem:activemodel 和 arel。
activemodel 為一個類增加了許多特性,比如屬性校驗,回調(diào)等,這在后面章節(jié)會介紹。
arel 是 Ruby 編寫的 sql 工具,使用它,可以通過簡單的 Ruby 語法,編寫復(fù)雜 sql 查詢,我們上面使用的例子,語法就來自 arel。arel 還可以面向多種關(guān)系型數(shù)據(jù)庫。
ActiveRecord 在使用 arel 的時候,提供了一個方法:sanitize_sql。
在我們以上的講解中,會經(jīng)常傳遞這樣的參數(shù) ["name = ? and price=?", "foobar", 4],它會由 sanitize_sql 方法進行處理,這是一個 protected 方法,我們使用 send 來調(diào)用它:
Product.send(:sanitize_sql, ["name = ? and price=?", "Shoes", 4])
=> "name = 'Shoes' and price=4"
這是一種安全的手段,保護我們的 sql 不會被插入惡意代碼。我們不必去直接使用這個方法,除非特殊情況,我們只需要按照它的格式要求來書寫就可以了。
N+1 是查詢中經(jīng)常遇到的一個問題。在下一節(jié)里,我們經(jīng)常使用關(guān)聯(lián)關(guān)系的查詢,比如,列出十個用戶的同時,顯示它地址中的電話:
users = User.limit(10)
users.each do |user|
puts user.address.phone
end
這樣就會造成,在 each 中又去查詢數(shù)據(jù),得到電話。這種情況會經(jīng)常出現(xiàn)在我的列表中,所以在列表中會經(jīng)常遇到 N+1 的問題。
為了避免這個問題,Rails 提供了預(yù)加載的功能,在查詢的時候,使用 includes 來解決。上面的例子修改一下:
users = User.includes(:address).limit(10)
users.each do |user|
puts user.address.phone
end
我們查看一下終端的輸出:
SELECT * FROM users LIMIT 10
SELECT addresses.* FROM addresses
WHERE (addresses.user_id IN (1,2,3,4,5,6,7,8,9,10))
這里只有兩個 sql 查詢,提高了查詢效率。
當我們使用 where 查詢的時候,會遇到多個條件組合查詢。通常我們可以把它們都寫到一個 where 的條件里,比如:
Product.where(name: "T-Shirt", hot: true, top: true)
我增加了兩個條件,hot: true 和 top: true,但是,這種條件組合只能在這里使用,在其他地方,我們還要再寫一遍,這不符合 Rails 的哲學(xué):“不要重復(fù)自己”。
Rails 提供了 scope,讓我們復(fù)用查詢條件:
class Product < ActiveRecord::Base
scope :hot, -> { where(hot: true) }
scope :top, -> { where(top: true) }
end
使用的時候,我們可以將多個 scope 組合在一起:
Product.top.hot.where(name: "T-Shirt")
default_scope 可以為所有查詢加上它定義的查詢條件,比如:
class Product < ActiveRecord::Base
default_scope { where("deleted_at IS NULL") }
end
default_scope 要慎用,慎用,慎用(重要的話說三遍),在我們程序變的復(fù)雜的時候,性能往往會消耗在數(shù)據(jù)庫查詢上,維護已有查詢時,很容易忽視 default_scope 的作用。如果使用了 default_scope,而在其他地方不得不去掉它,可以使用 unscoped,然后再附上其他查詢:
Product.unscoped.load.top.hot
如果一個地方使用了某個 scope,而要在另一個地方把它的條件改變,可以使用 merge:
class Product < ActiveRecord::Base
scope :active, -> { where state: 'active' }
scope :inactive, -> { where state: 'inactive' }
end
看一下它的執(zhí)行結(jié)果:
Product.active.merge(User.inactive)
# SELECT "products".* FROM "products" WHERE "products"."state" = 'inactive'
我們使用where查詢,得到的是 ActiveRecord::Relation 實例,它的源代碼在這里。閱讀這里的代碼,會讓你學(xué)習(xí)到更多優(yōu)雅的查詢方法。在查詢時,我們還可以使用 sql 直接查詢,如果你更熟悉 sql 語法,可以這樣來查詢:
Client.find_by_sql("SELECT * FROM clients
INNER JOIN orders ON clients.id = orders.client_id
ORDER BY clients.created_at desc")
# => [
#<Client id: 1, first_name: "Lucas" >,
#<Client id: 2, first_name: "Jan" >,
# ...
]
這個例子來自這里。
它返回的是實例的集合,這在我們 Rails 內(nèi)使用很方便,但是提供 json 格式的 api時,需要轉(zhuǎn)換一下,不過我們可以用 select_all 查詢,得到包含 hash 的 array:
Client.connection.select_all("SELECT first_name, created_at FROM clients WHERE id = '1'")
# => [
{"first_name"=>"Rafael", "created_at"=>"2012-11-10 23:23:45.281189"},
{"first_name"=>"Eileen", "created_at"=>"2013-12-09 11:22:35.221282"}
]
pluck 可以直接在 Relation 實例的基礎(chǔ)上,使用 sql 的 select 方法,得到字段值的集合(Array),而不用把返回結(jié)果包裝成 ActiveRecord 實例,再得到屬性值。在查詢屬性集合時,pluck 的性能更高。
Client.where(active: true).pluck(:id)
SELECT id FROM clients WHERE active = 1
=> [1, 2, 3]
Client.distinct.pluck(:role)
SELECT DISTINCT role FROM clients
=> ['admin', 'member', 'guest']
Client.pluck(:id, :name)
SELECT clients.id, clients.name FROM clients
=> [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]
ActiveRecord 有一個類似的方法,select,比較下兩者的區(qū)別:
Product.select(:id, :name)
Product Load (8.5ms) SELECT "products"."id", "products"."name" FROM "products"
=> #<ActiveRecord::Relation [#<Product id: 1, name: "f">]>
Product.pluck(:id, :name)
(0.3ms) SELECT "products"."id", "products"."name" FROM "products"
=> [[1, "f"]]
前者顯示返回 AR 實例,然后取其屬性值,后者直接讀取數(shù)據(jù)庫記錄,返回數(shù)組。
pluck 只能用在查詢的最后,因為它直接返回了結(jié)果,而不是 ActiveRecord::Relation。
ids 返回主鍵集合:
Person.ids
=> SELECT id FROM people
不要被 ids 字面迷惑,它返回的是主鍵的集合,我們可以在 model 里設(shè)定其他字段為主鍵。
class Person < ActiveRecord::Base
self.primary_key = "person_id"
end
Person.ids
=> SELECT person_id FROM people
這里有四個方法,方便我們判斷一個模型中的記錄數(shù)量。
Client.exists?(1)
Client.exists?(id: [1,2,3])
Client.exists?(name: ['John', 'Sergei'])
exists? 判斷記錄是否存在,和它類似的方法有兩個:
Client.exists? [1]
Client.any? [2]
Client.many? [3]
[1] 是否有記錄 [2] 是否至少有一條記錄 [3] 是否有多于一條的記錄
any? 和 many? 與 exists? 不同的是,他們可以使用在 Relation 實例上,比如:
Article.where(published: true).any?
Article.where(published: true).many?
還可以接收 block:
person.pets.any? do |pet|
pet.group == 'cats'
end
=> false
person.pets.many? do |pet|
pet.group == 'dogs'
end
=> true
下面五個方法,完全可以按照字面意義理解,并且適用于 Relation 上:
Client.count
Client.average("orders_count")
Client.minimum("age")
Client.maximum("age")
Client.sum("orders_count")
以上的例子來自 這里,閑暇的時候應(yīng)該多讀讀這個文檔,翻看源碼。
在深入 Rails 項目開發(fā)之后,測試環(huán)節(jié)是一個重要的環(huán)節(jié)。Ruby 為我們提供了非常方便的測試框架,Rails 也可以方便的執(zhí)行這些測試框架。
在 Rails 3.x 及之前的版本里,默認使用 TestUnit 框架,4.x 之后改為 MiniTest 框架。我們可以查看 test_case.rb 文件,看到其中的變化。
除了這兩個測試框架,Rspec 也是經(jīng)常用到的 Ruby 測試框架。
我們在 Rails 里安裝 rpesc,和其他的幾個 gem:
group :development, :test do
gem 'rspec-rails'
gem "factory_girl_rails"
gem "database_cleaner"
end
rspec-rails 是 rspec 的 Rails 集成,在 Rails 中初始化 rspec 的命令是:
rails generate rspec:install
它會創(chuàng)建兩個文件,和 spec 文件件。運行 rpsec 測試的命令非常簡單,rspec 就可以,他會自動運行 spec 文件夾下所有的 xxx_spec.rb 文件,也可以指定某個文件:
rspec spec/models/product_spec.rb
也可以只運行某一個測試用例,這需要指定該用例開始的行數(shù):
rspec spec/models/product_spec.rb:10
也可以運行某一個目錄:
rspec spec/models/
factory_girl_rails 是 factory_girl 的 Rails 包裝。factory_girl 可以為我們的測試代碼提供模擬的測試數(shù)據(jù)。
database_cleaner 可以在每一次運行測試的時候,清空測試數(shù)據(jù)庫。我們在 config/database.yml 中,會設(shè)置三種運行環(huán)境,test 環(huán)境要單獨設(shè)置數(shù)據(jù)庫,也就是因為測試時會反復(fù)填入和刪除數(shù)據(jù)。一般,test 使用的是 sqlite 數(shù)據(jù)庫,而 production 使用 mysql、postgresql 等數(shù)據(jù)庫。
我們需要配置下 spec 的運行環(huán)境:
RSpec.configure do |config|
config.before(:each) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean
end
end
在使用 generator 創(chuàng)建 model 文件的時候,rspec 會自動創(chuàng)建它對應(yīng)的 spec 文件。我們打開 product_spec.rb 文件:
require 'rails_helper'
RSpec.describe Product, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
我們?yōu)樗黾右粋€測試:
RSpec.describe Product, type: :model do
it "should create a product" do
tshirt = Product.create(name: "T-Shirt", price: 9.99)
expect(tshirt.name).to eq("T-Shirt")
expect(tshirt.price).to eq(9.99)
end
end
運行一下這個測試:
rspec spec/models/product_spec.rb
.
Finished in 0.081 seconds (files took 2.37 seconds to load)
1 example, 0 failures
這個測試的目的,是確保 create 方法可以為我們創(chuàng)建一個 product 實例。更多 rspec 語法可以查看 rspec 文檔,或者 《使用 RSpec 測試 Rails 程序》一書。