ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 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] => #<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,创建了一条记录。我们还有其他的方法保存记录。 [2],begin 和 commit ,将我们的数据保存入数据库。如果在保存的时候出现错误,比如属性校验失败,抛出异常等,不会将记录保存到数据库。 [3],我们拿到了一个 Product 类的实例。 除了类方法,我们还可以使用实例的 `save` 方法,来保存记录到数据,比如: ~~~ > 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,来创建一个实例,注意,[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 => #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil> 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!` 将它真正的删除掉。