模型的基础操作
最后更新于:2022-04-01 22:44:51
# 4.1 模型的基础操作
## 概要:
本课时讲解模型的基础操作,数据迁移,常用的 CRUD 方法,在数据查询时,如何避免 N+1问题,如何使用 scope 包装查询条件,编写模型 Rspec 测试。
## 知识点:
1. Active Record
1. Migration
1. CRUD
## 正文
### 4.1.1 Active Record 简介
Active Record 模式,是由 Martin Fowler 的《企业应用架构模式》一书中提出的,在该模式中,一个 Active Record(简称 AR)对象包含了持久数据(保存在数据库中的数据)和数据操作(对数据库里的数据进行操作)。
对象关系映射(Object-Relational Mapping,简称 ORM),是将程序中的对象(Object)和关系型数据库(Relational Database)的表之间进行关联。使用 ORM 可以方便的将对象的 `属性` 和 `关联关系` 保存入数据库,这样可以不必编写复杂的 SQL 语句,而且不必担心使用的是哪种数据库,一次编写的代码可以应用在 Sqlite,Mysql,PostgreSQL 等各种数据库上。
Active Record 就是个 ORM 框架。
所以,我们可以用 Actice Record 来做这几件事:
- 表示模型(Model)和模型数据
- 表示模型间的关系(比如一对多,多对多关系)
- 通过模型间关联表示继承层次
- 在保存如数据库前,校验模型(比如属性校验)
- 用 `面向对象` 的方式处理数据库
### 4.1.2 Active Record 中的约定
Rails 中使用了 ActiveRecord 这个 Gem,使用它可以不必去做任何配置(大多数情况是这样的),还记得 Rails 的两个哲学理念之一么:`约定优于配置`。(另一个是 `不要重复自己`,这是 Dave Thomas 在《程序员修炼之道》一书里提出的。)
那么,我们讲两个 Active Record 中的约定:
#### 4.1.2.1 命名约定
- 数据表名:复数,下划线分隔单词(例如 book_clubs)
- 模型类名:单数,每个单词的首字母大写(例如 BookClub)
比如:
| 模型(Class) | 数据表(Schema) |
|-----|-----|
| Post | posts |
| LineItem | line_items |
| Deer | deers |
| Mouse | mice |
| Person | people |
单词在单复数转换时,是按照英文语法约定的。
#### 4.1.2.2 Schema 约定
注:数据库中的 Schema,指数据库对象集合,可以被用户直接使用。Schema 包含数据的逻辑结构,用户可以通过命名调用数据库对象,并且安全的管理数据库。
- 外键 - 使用 singularized_table_name_id 形式命名,例如 item_id,order_id。创建模型关联后,Active Record 会查找这个字段;
- 主键 - 默认情况下,Active Record 使用整数字段 id 作为表的主键。使用 Active Record 迁移创建数据表时,会自动创建这个字段;
在数据库字段命名的时候,有几个特殊意义的名字,尽量回避:
- created_at - 创建记录时,自动设为当前的时间戳
- updated_at - 更新记录时,自动设为当前的时间戳
- lock_version - 在模型中添加乐观锁定功能
- type - 让模型使用单表继承,给字段命名的时候,尽量避开这个词
- (association_name)_type - 多态关联的类型
- (table_name)_count - 保存关联对象的数量。例如,posts 表中的 comments_count 字段,Rails 会自动更新该文章的评论数
### 4.1.3 数据库迁移(Migration)
在我们使用 scaffold 创建资源的时候,或者使用 generate 创建 model 的时候,Rails 会给我们自动创建一个数据库迁移文件,它在 `db/migrate` 中,它的前缀是时间戳,他们按照时间的先后顺序排列,当运行数据库迁移时,他们按照时间顺序先后被执行。
新创建的迁移文件,我们使用 `rake db:migrate` 命令执行它(们),这里会判断,哪个迁移文件是还没有被执行的。
如果我们对执行过的迁移操作不满意,我们可以回滚这个迁移:
~~~
rake db:rollback [1]
rake db:rollback STEP=3 [2]
~~~
[1] 回滚最近的一个迁移
[2] 回滚指定的迁移个数
回滚之后,迁移停留在回滚到的那个位置的,schema 也会更新到那个位置时的状态。比如,我们上一次迁移执行了5个文件,我们回滚的时候,是一个个文件回滚的,所以我们指定 STEP=5,才能把刚才迁移的5个文件回滚。
在我们开发代码的过程中,有是会因为失误少写了一个字段,我们回滚之后,在迁移文件中把它加上,然后,我们 `rake db:migrate` 再次运行。不过,`rake db:migrate:redo [STEP=3]` 直接回滚然后再次运行迁移,这样会方便些。
这种回滚操作适合开发过程中,出现了新的想法,而回滚最近连续的几个迁移。
如果我们想回滚很久以前的某个操作,而且在那个迁移之后,我们已经执行了多个迁移。这时该如何处理呢?
如果在开发阶段,我们干脆 `rake db:drop`,`rake db:create`,`rake db:migrate`。但是在生产环境,我们决不能这么做,这时我们要针对需求,编写一个迁移文件:
~~~
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`用于回滚。
我们创建一个 model 的时候,会自动创建它的 migration 文件,我们还可以使用 `rails g migration XXX`的方法,添加自定义的迁移文件。如果我们的命名是 "AddXXXToYYY" 或者 "RemoveXXXFromYYY" 时,会自动为我们添加字符类型的字段,比如我为 variant 添加一个color 字段:
~~~
rails g migration AddColorToVariants color:string
~~~
它的内容是:
~~~
class AddColorToVariants < ActiveRecord::Migration
def change
add_column :variants, :color, :string
end
end
~~~
### 4.1.4 CRUD
CRUD并不是一个 Rails 的概念,它表示系统(业务层)和数据库(持久层)之间的基本操作,简单的讲叫“增(C)删(D)改(U)查(R)”。
我们已经使用 scaffold 命令创建了资源:商品(product),我们现在使用 `app/models/product.rb` 来演示这些操作。
首先,我们需要让 Product 类继承 ActiveRecord:
~~~
class Product < ActiveRecord::Base
end
~~~
这样,Product 类就可以操作数据库了,是不是很简单。
### 4.1.5 创建记录
我们使用 Product 类,向数据添加一条记录,我们先进入 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]
=> # [3]
~~~
这里,我贴出了完整的代码。
[1],我们使用了 Product 的类方法 create,创建了一条记录。我们还有其他的方法保存记录。
[2],begin 和 commit ,将我们的数据保存入数据库。如果在保存的时候出现错误,比如属性校验失败,抛出异常等,不会将记录保存到数据库。
[3],我们拿到了一个 Product 类的实例。
除了类方法,我们还可以使用实例的 `save` 方法,来保存记录到数据,比如:
~~~
> product = Product.new [1]
=> # [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,来创建一个实例,注意,[2] 告诉我们,这是一个没有保存到数据库的实例,因为它的 id 还是 nil。
[3] 我们使用实例方法 save,把这个实例,保存到数据库。
[4] 调用 save 后,会返回执行结果,true 或者 false。这种判断很有用,而且也很常见,如果你现在打开 `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
=> #
2.2.0 :003 >
~~~
答案揭晓,它返回给我们一个未保存的实例,它有一个实用的方法,可以查看哪里出了错误:
~~~
> product.errors.full_messages
=> ["名称不能为空字符"]
~~~
当然,判断一个实例是否保存成功,不必去检查它的 errors 是否为空,有两个方法会根据 errors 是否添加,而返回实例的状态:
~~~
person = Person.new
person.invalid?
person.valid?
~~~
要留意的是,invalid? 和 valid? 都会调用实例的校验。
我使用类方法和实例方法的称呼,希望没有给你造成理解的障碍,如果有些难理解,建议你先看一看 Ruby 中关于类和实例的介绍。
### 4.1.6 查询记录
#### 4.1.6.1 Find 查询
数据查询,是 Rails 项目经常要做的操作,如何拿到准确的数据,优化查询,是我们要重点关注的。
查询时,会得到两种结果,一个实例,或者实例的集合(Array)。如果找不到结果,也会给有两种情况,返回 nil或空数组,或者抛出 ActiveRecord::RecordNotFound 异常。
Rails 给我们提供了这些常用的查询方法:
| 方法名称 | 含义 | 参数 | 例子 | 找不到时 |
|-----|-----|-----|-----|-----|
| find | 获取指定主键对应的对象 | 主键值 | 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!。
如果将上面几个方法的参数改动,我们就会得到集合:
| 方法名称 | 含义 | 参数 | 例子 | 找不到时 |
|-----|-----|-----|-----|-----|
| find | 获取指定主键对应的对象 | 主键值集合 | Product.find([1,2,3]) | 异常 |
| take | 获取一个记录,不考虑任何顺序 | 个数 | Product.take(2) | [] |
| first | 获取按主键排序得到的第N个记录 | 个数 | Product.first(3) | [] |
| last | 获取按主键排序得到的最后N个记录 | 个数 | Product.last(4) | [] |
| all | 获取按主键排序得到的全部记录 | 无 | Product.all | [] |
Rails 还提供了一个 find_by 的查询方法,它可以接收多个查询参数,返回符合条件的第一个记录。比如:
~~~
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)
~~~
如果需要查询不到结果抛出异常,可以使用 `find_by!`。通常,以`!`结尾的方法都会抛出异常,这也是一种约定。不过,直接使用 find,会查询主索引,查询不到直接抛出异常,所以是没有 `find!` 方法的。
使用 find_by 的时候,还可以使用 sql 语句,比如:
~~~
Product.find_by("name = ?", "T")
~~~
这是一个有用的查询,当我们搜索多个条件,并且是 OR 关系时,可以这样做:
~~~
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])
~~~
#### 4.1.6.2 Where 查询
集合的查找,最常用的方法是 `where`,它可以通过多种形式查找记录:
| 查询形式 | 实例 |
|-----|-----|
| 数组(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%")
~~~
查询某个区间:
~~~
Product.where(price: 5..6)
~~~
以及上面提到的,sql 的查询:
~~~
Product.where("color = ? OR price > ?", "red", 9)
~~~
Active Record 有多种查询方法,以至于 Rails 手册中单独列出一章来讲解,而且讲解的很细致,如果你想灵活的掌握这些数据查询方法,建议你经常阅读 [Active Record Query Interface](http://guides.rubyonrails.org/active_record_querying.html) 一章,这是 [中文版](http://guides.ruby-china.org/active_record_querying.html)。
### 4.1.7 更新记录(Update)
和创建记录一样,更新记录也可以使用类方法和实力方法。
类方法是 update,比如:
~~~
Product.update(1, name: "T-Shirt", price: 23)
~~~
1 是更新目标的 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 更新记录的时候,会调用 Model 的 validates(校验) 和 callbacks(回调),保证我们写入正确的数据,这个是定义在 Model 中的方法。但是,update_all 会略过校验和回调,直接将数据写入到数据库中。
和 update_all 类似,update_column/update_columns 也是将数据直接写入到数据库,它是一个实例方法:
~~~
product = Product.first
product.update_column(:name, "")
product.update_columns(name: "", price: 0)
~~~
虽然为 product 增加了 name 非空的校验,但是 update_column(s) 还是可以讲数据写入数据库。
当我们创建迁移文件的时候,Rails 默认会添加两个时间戳字段,created_at 和 updated_at。
当我们使用 update 更新记录时,触发 Model 的校验和回调时,也会自动更新 updated_at 字段。但是 Model.update_all 和 model.update_column(s) 在跳过回调和校验的同时,也不会更新 updated_at 字段。
我们也可以用 save 方法,将新的属性保存到数据库,这也会触发调用和回调,以及更新时间戳:
~~~
product = Product.first
product.name = "Shoes"
product.save
~~~
### 4.1.8 删除记录(Destroy)
在我们接触计算机英语里,表示删除的英文有很多,这里我们用到的是 destroy, delete。
#### 4.1.8.1 Delete 删除
使用 delete 删除时,会跳过回调,以及关联关系中定义的 `:dependent` 选项,直接从数据库中删除,它是一个类方法,比如:
~~~
Product.delete(1)
Product.delete([2,3,4])
~~~
当传入的 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 方法查找记录,然后调用 delete_all。
delete 也可以是实例方法,比如:
~~~
product = Product.first
product.delete
~~~
在有具体实例的时候,可以这样使用,否则会产生 `NoMethodError: undefined method`delete' for nil:NilClass`,这在我们设计逻辑的时候要注意。
delete_all 方法和 delete 是一样的,直接发送数据删除的命令,看一下 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
~~~
#### 4.1.8.2 Destroy 删除
destroy 方法,会触发 model 中定义的回调(before_remove, after_remove , before_destroy 和 after_destroy),保证我们正确的操作。它也可以是类方法和实例方法,用法和前面的一样。
需要说明,delete/delete_all 和 destroy/destroy_all 都可以作用在关系查询结果,也就是(ActiveRecord::Relation)上,删掉查找到的记录。
如果你不想真正从数据库中抹掉数据,而是给它一个删除标注,可以使用 [https://github.com/radar/paranoia](https://github.com/radar/paranoia) 这个 gem,他会给记录一个 deleted_at 时间戳,并且使用 `restore` 方法把它从数据库中恢复过来,或者使用 `really_destroy!` 将它真正的删除掉。
';