# 4.2 深入模型查詢
## 概要:
本課時講解模型在數據查詢時,如何避免 N+1問題,使用 scope 包裝查詢條件,編寫模型 Rspec 測試。
## 知識點:
1. N+1
1. Scope
1. 實用的查詢
1. Rspec 測試
## 正文
### 4.2.1 兩個 Gem
ActiveRecord 這個 gem 中,包含了兩個重要的 gem,打開它的 [源代碼](https://github.com/rails/rails/blob/master/activerecord/activerecord.gemspec),可以看到這兩個 gem:[activemodel](https://github.com/rails/rails/tree/master/activemodel) 和 [arel](https://github.com/rails/arel)。
`activemodel` 為一個類增加了許多特性,比如屬性校驗,回調等,這在后面章節會介紹。
`arel` 是 Ruby 編寫的 sql 工具,使用它,可以通過簡單的 Ruby 語法,編寫復雜 sql 查詢,我們上面使用的例子,語法就來自 arel。arel 還可以面向多種關系型數據庫。
ActiveRecord 在使用 arel 的時候,提供了一個方法:sanitize_sql。
在我們以上的講解中,會經常傳遞這樣的參數 `["name = ? and price=?", "foobar", 4]`,它會由 `sanitize_sql` 方法進行處理,這是一個 protected 方法,我們使用 send 來調用它:
~~~
Product.send(:sanitize_sql, ["name = ? and price=?", "Shoes", 4])
=> "name = 'Shoes' and price=4"
~~~
這是一種安全的手段,保護我們的 sql 不會被插入惡意代碼。我們不必去直接使用這個方法,除非特殊情況,我們只需要按照它的格式要求來書寫就可以了。
### 4.2.2 N+1
N+1 是查詢中經常遇到的一個問題。在下一節里,我們經常使用關聯關系的查詢,比如,列出十個用戶的同時,顯示它地址中的電話:
~~~
users = User.limit(10)
users.each do |user|
puts user.address.phone
end
~~~
這樣就會造成,在 each 中又去查詢數據,得到電話。這種情況會經常出現在我的列表中,所以在列表中會經常遇到 N+1 的問題。
為了避免這個問題,Rails 提供了預加載的功能,在查詢的時候,使用 `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 查詢,提高了查詢效率。
### 4.2.3 查詢中使用 Scope
當我們使用 where 查詢的時候,會遇到多個條件組合查詢。通常我們可以把它們都寫到一個 where 的條件里,比如:
~~~
Product.where(name: "T-Shirt", hot: true, top: true)
~~~
我增加了兩個條件,`hot: true` 和 `top: true`,但是,這種條件組合只能在這里使用,在其他地方,我們還要再寫一遍,這不符合 Rails 的哲學:“不要重復自己”。
Rails 提供了 scope,讓我們復用查詢條件:
~~~
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` 要慎用,慎用,慎用(重要的話說三遍),在我們程序變的復雜的時候,性能往往會消耗在數據庫查詢上,維護已有查詢時,很容易忽視 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
~~~
看一下它的執行結果:
~~~
Product.active.merge(User.inactive)
# SELECT "products".* FROM "products" WHERE "products"."state" = 'inactive'
~~~
### 4.2.4 實用的查詢
### 4.2.4.1 sql 查詢集合
我們使用where查詢,得到的是 ActiveRecord::Relation 實例,它的源代碼在[這里](https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation.rb)。閱讀這里的代碼,會讓你學習到更多優雅的查詢方法。在查詢時,我們還可以使用 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" >,
# ...
]
~~~
這個例子來自[這里](http://guides.rubyonrails.org/active_record_querying.html#dynamic-finders)。
它返回的是實例的集合,這在我們 Rails 內使用很方便,但是提供 json 格式的 api時,需要轉換一下,不過我們可以用 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"}
]
~~~
### 4.2.4.2 pluck
pluck 可以直接在 Relation 實例的基礎上,使用 sql 的 select 方法,得到字段值的集合(Array),而不用把返回結果包裝成 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,比較下兩者的區別:
~~~
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 實例,然后取其屬性值,后者直接讀取數據庫記錄,返回數組。
pluck 只能用在查詢的最后,因為它直接返回了結果,而不是 ActiveRecord::Relation。
### 4.2.4.3 ids
ids 返回主鍵集合:
~~~
Person.ids
=> SELECT id FROM people
~~~
不要被 ids 字面迷惑,它返回的是主鍵的集合,我們可以在 model 里設定其他字段為主鍵。
~~~
class Person < ActiveRecord::Base
self.primary_key = "person_id"
end
Person.ids
=> SELECT person_id FROM people
~~~
### 4.2.4.4 查詢記錄數量
這里有四個方法,方便我們判斷一個模型中的記錄數量。
~~~
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
~~~
### 4.2.4.5 查詢記錄數量
下面五個方法,完全可以按照字面意義理解,并且適用于 Relation 上:
~~~
Client.count
Client.average("orders_count")
Client.minimum("age")
Client.maximum("age")
Client.sum("orders_count")
~~~
以上的例子來自 [這里](http://guides.rubyonrails.org/active_record_querying.html),閑暇的時候應該多讀讀這個文檔,翻看源碼。
### 4.2.5 Rspec 測試
在深入 Rails 項目開發之后,測試環節是一個重要的環節。Ruby 為我們提供了非常方便的測試框架,Rails 也可以方便的執行這些測試框架。
在 Rails 3.x 及之前的版本里,默認使用 [TestUnit](https://github.com/test-unit/test-unit) 框架,4.x 之后改為 [MiniTest](https://github.com/seattlerb/minitest) 框架。我們可以查看 [test_case.rb](https://github.com/rails/rails/blob/master/activesupport/lib/active_support/test_case.rb) 文件,看到其中的變化。
除了這兩個測試框架,[Rspec](https://github.com/rspec/rspec) 也是經常用到的 Ruby 測試框架。
我們在 Rails 里安裝 rpesc,和其他的幾個 gem:
~~~
group :development, :test do
gem 'rspec-rails'
gem "factory_girl_rails"
gem "database_cleaner"
end
~~~
[rspec-rails](https://github.com/rspec/rspec-rails) 是 [rspec](http://rspec.info/) 的 Rails 集成,在 Rails 中初始化 rspec 的命令是:
~~~
rails generate rspec:install
~~~
它會創建兩個文件,和 spec 文件件。運行 rpsec 測試的命令非常簡單,`rspec` 就可以,他會自動運行 spec 文件夾下所有的 xxx_spec.rb 文件,也可以指定某個文件:
~~~
rspec spec/models/product_spec.rb
~~~
也可以只運行某一個測試用例,這需要指定該用例開始的行數:
~~~
rspec spec/models/product_spec.rb:10
~~~
也可以運行某一個目錄:
~~~
rspec spec/models/
~~~
[factory_girl_rails](https://github.com/thoughtbot/factory_girl_rails) 是 [factory_girl](https://github.com/thoughtbot/factory_girl) 的 Rails 包裝。factory_girl 可以為我們的測試代碼提供模擬的測試數據。
[database_cleaner](https://github.com/DatabaseCleaner/database_cleaner) 可以在每一次運行測試的時候,清空測試數據庫。我們在 config/database.yml 中,會設置三種運行環境,test 環境要單獨設置數據庫,也就是因為測試時會反復填入和刪除數據。一般,test 使用的是 sqlite 數據庫,而 production 使用 mysql、postgresql 等數據庫。
我們需要配置下 spec 的運行環境:
~~~
RSpec.configure do |config|
config.before(:each) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean
end
end
~~~
#### 4.2.5.1 Model 測試
在使用 generator 創建 model 文件的時候,rspec 會自動創建它對應的 spec 文件。我們打開 product_spec.rb 文件:
~~~
require 'rails_helper'
RSpec.describe Product, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
~~~
我們為它增加一個測試:
~~~
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 方法可以為我們創建一個 product 實例。更多 rspec 語法可以查看 rspec 文檔,或者 [《使用 RSpec 測試 Rails 程序》](https://selfstore.io/products/3)一書。
- 寫在前面
- 第一章 Ruby on Rails 概述
- Ruby on Rails 開發環境介紹
- Rails 文件簡介
- 用戶界面(UI)設計
- 第二章 Rails 中的資源
- 應用 scaffold 命令創建資源
- REST 架構
- 深入路由(routes)
- 第三章 Rails 中的視圖
- 布局和輔助方法
- 表單
- 視圖中的 AJAX 交互
- 模板引擎的使用
- 第四章 Rails 中的模型
- 模型的基礎操作
- 深入模型查詢
- 模型中的關聯關系
- 模型中的校驗
- 模型中的回調
- 第五章 Rails 中的控制器
- 控制器中的方法
- 控制器中的邏輯
- 第六章 Rails 的配置及部署
- Assets 管理
- 緩存及緩存服務
- 異步任務及郵件發送
- I18n
- 生產環境部署
- 常用 Gem
- 寫在后面