# 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
- 写在后面