Active Record 是由 Rails 提供的对象关系映射(ORM)层,也是实现 Rails 应用中 model 的一部分。
在本章中,我们将构建在 Depot 应用中的数据及字符的映射。接着,我们将了解通过 Active Record 管理表关系的知识,也会学习创建、读取、更新和删除操作的过程(也就是通常说的 CRUD 方法)。最后,我们还将深入学习 Active Record 对象的生命周期(包括回调和事务)。
定义数据
在 Depot 中,除了 Order 之外我们还定义了其他 model。订单 model 包含一些属性,比如字符串类型的 email 地址。在我们定义的属性之外,Rails 还提供了 id 属性,它包含了数据的主键。当然,除此之外 Rails 还提供了几个额外属性,主要用于记录数据被创建和最后更新的时间。Rails 也支持 model 间的关联关系,比如订单与购买商品间的关系。
除了上述提到的特性之外,Rails 还为 model 提供了许多功能,接下来让我们逐个学习。
规划表和字段
每个 ActiveRecord::Base
的子类都表示一个独立的数据库表,比如 Order 类。Active Record 默认表名为相应类名的复数形式,如果类名由多个单词组成,默认情况下表名由被下划线分隔的多个单词组成。
Classname | Table Name |
---|---|
Order | orders |
TaxAgency | tax_agencies |
Batch | batches |
Diagnosis | diagnoses |
LineItem | line_items |
Person | people |
Datum | data |
Quantity | quantities |
这些规则都表达了 Rails 的哲学,类名应该为单数形式,而表名应该为相应的复数形式。
尽管 Rails 可以处理许多不规则复数,但偶尔还是有意外情况。如果你遭遇了类似情况,可以通过在相应映射文件中添加复数与单数单词的对照解决。
# config/initializers/inflections.rb
# Be sure to restart your server when you modify this file.
# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, '\1en'
# inflect.singular /^(ox)en/i, '\1'
# inflect.irregular 'person', 'people'
# inflect.uncountable %w( fish sheep )
# end
# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym 'RESTful'
# end
ActiveSupport::Inflector.inflections do |inflect|
inflect.irregular 'tax', 'taxes'
end
如果有遗留系统的表需要处理,或者你并不喜欢 Rails 提供的默认方式,可以通过向指定类提供对应的表名自己控制。
class Sheep < ActiveRecord::Base
self.table_name = "sheep"
end
David 提问:表字段又在哪里呢?
基于数据库管理员(DBA)与开发人员是两个分离角色的思想,所以对代码和 schema 之间也进行了严格的隔离。Active Record 模糊了这种区别,再没有其他地方比 model 缺少属性定义更加明显了。
不过不需要担心,实践表明无论是查看数据库或者分离的 XML 映射文件,亦或者 model 中的属性其实并没有分别。这种合成视图与 MVC 模式中的分隔十分相似,只是发生在不同的尺度上而已。
一旦适应了将 schema 视作 model 定义的一部分时,你将理解遵循 DRY 所带来的收益。当你需要向 model 添加一个属性时,只要创建新 migration 然后重新加载应用即可。
将构建步骤从 schema 剥离是使余下代码更加敏捷的演进。这样可以使创建一个小型 schema,按需要继承及修改都更加容易。
Active Record 类的实例表示数据库表中的数据,而对象中的属性与表的列相对应。你可能已经注意到,Order 类并没有提及 orders 表中的任何列,这都是由于 Active Record 决定在运行时处理相关内容。Active Record 在将 schema 注入数据库时才反射配置表示相应表的类。
在 Depot 中,orders 表由下列 migration 定义:
# db/migrate/20201218093951_create_orders.rb
class CreateOrders < ActiveRecord::Migration
def change
create_table :orders do |t|
t.string :name
t.text :address
t.string :email
t.string :pay_type
t.timestamps null: false
end
end
end
让我们通过 rails console
命令操作 model。首先,当然是获取列名列表。
rails console
Order.column_names
接着,再获取 pay_type 列的详情。
Order.columns_hash["pay_type"]
注意 Active Record 已经收集了相当多的 pay_type 列信息。通过信息我们可以了解到,该字段是最多 255 字符的字符串,并没有默认值,而且它并不是主键,有可能存在空值。当首次使用 Order 类时 Rails 会通过底层的数据库获得相应信息。
一般情况下,Active Record 实例的属性与数据库表的数据相吻合。例如,orders 表可能存在下列数据:
sqlite3 -line db/development.sqlite3 "select * from orders limit 1"
如果获取此数据转换为 Active Record 对象,此对象将拥有七个属性。id 属性将是数字 1,name 属性是字符串「Dave Thomas」等等。
我们通过访问方法获取相应属性的值。当 Rails 通过 schema 反射生成 model 对象时,也会自动为它创建属性访问和更新方法。
o = Order.find(1)
puts o.name #=> "Dave Thomas"
o.name = "Fred Smith" # set the name
只是设置属性值并不会最影响数据库数据,我们还需要将对象的变动进行存储才会产生最影响。
属性读取方法返回的值是由 Active Record 转换为适当的 Ruby 类型(比如,如果数据库字段为时间戳,对应将返回 Time 对象)。如果希望获取原生的属性值,可以在名称后添加 _before_type_cast
获取,如下代码展示:
product.price_before_type_cast #=> 34.95, a float
product.updated_at_before_type_cast #=> "2013-02-13 10:13:14"
如果是 model 的内部代码,可以通过 read_attribute()
和 write_attribute()
私有方法,此方法需要将属性名作为参数。
在下表中,我们将看到 SQL 类型与其 Ruby 类型的对应关系。Decimal 和 Boolean 字段有一些棘手。
SQL Type | Ruby Class |
---|---|
int, integer | Fixnum |
float, double | Float |
decimal, numeric | BigDecimal |
char, varchar, string | String |
interval, date | Date |
datetime, time | Time |
clob, blob, text | String |
boolean | See text |
按道理 Decimal 应该与 Fixnum 对象对应,然而它却对应了 BigDecimal 对象,这是为了确保小数位不会丢失。
在 Boolean 的示例中,为了方便表示,方法尾部一般都跟随问号。
user = User.find_by(name: "Dave")
if user.superuser?
grant_privileges
end
除了这些由我们定义的字段外,Rails 也自动提供了一些其他的属性,其中有些属性有着特殊的意义。
由 Active Record 提供的其他字段
有一些列名对 Active Record 格外重要,这里有一些总结:
create_at, create_on, updated_at, updated_on
当数据被创建或更新时这些列会自动记录创建时间或最后更新时间。不过需要确认底层数据库字段支持 date、datetime 或 string 类型。Rails 习惯用 _on
后缀的字段表示日期,而 _at
后缀的字段表示时间。
id
默认情况下这是表主键字段名。
xxx_id
默认情况下此字段表示外键名称,xxx 为外键关联的表名。
xxx_count
此字段为子表 xxx 维护了一个计数器缓存。
使用其他插件时也可能会产生额外的字段,比如 acts_as_list
。
主键与外键都是数据库操作中的重要角色,值得为其用新章节进行讲解。
定位及关联数据记录
在 Depot 中,LineItems 与其他三个 model 都有直接的关联,分别是 Cart、Order 和 Product,除此之外 model 之间也可以建立间接关系,Orders 与 Products 就是通过 LineItems 形成的间接联系。
这些关系都可以通过 ID 形成关联。
区分数据记录
Active Record 类与数据库中的表相对应,类的实例与表中的具体某条数据对应。比如,调用 Order.find(1)
将返回 Order 类的实例,它将包含主键为 1 的数据。
如果你为 Rails 应用创建了一个新 schema,根据正常流程 Rails 将向所有的表都添加 id 主键。但如果你是处理已经存在的 schema 时,Active Record 提供了简单的方法对表的主键名称进行覆盖。
例如,有一个遗留系统中 books 表是使用 ISBN 作为主键。如下就可以在 Active Record 的 model 中指定主键:
class LegacyBook < ActiveRecord::Base
self.primary_key = "isbn"
end
通常 Active Record 会更加关注为我们添加至数据库中的数据添加主键值,一般是增长的整型数据(也可能是一些序列)。不过,如果我们覆盖了主键名,也就需要在存储数据前为其主键设置唯一值。或许你会感到惊讶,我们依然通过设置 id 属性值来做到这一点。就像 Active Record 关注的一样,总是用名为 id 的属性作为主键,而 primary_key=
会声明用于表中的列名。下面的代码中,即使数据库的主键是 isbn,我们依然使用 id 属性:
book = LegacyBook.new
book.id = "0-12345-6789"
book.title = "My Great American Novel"
book.save
#...
book = LegacyBook.find("0-12345-6789")
puts book.title #=> "My Great American Novel"
p book.attributes #=> {"isbn" => "0-12345-6789", "title" => "My Great American Novel"}
生活总是让人困惑,model 对象的列只展示了 isbn 和 title,而 id 却没有出现。只有在设置主键值时才使用 id,其他时候都得使用真正的主键名。
model 对象也重定义了 Ruby 的 id()
和 hash()
方法,用于展示 model 主键。这也就意味着拥有有效 ID 的 model 对象将被用于 hash 主键。也就是说没有存储的 model 对象无法使用 hash 键值(因为未存储的对象不会拥有有效 ID)。
还有最后一点需要注意,如果两个 model 对象为相同类的实例,并且拥有一样的主键,Rails 将认为它们相等(也就是 == 成立),也就是说尚未存储的 model 对象即使属性不相同,但依然相等。如果你发现自己正在比较未存储的 model 对象时(这并不是一种常见的操作),应该要重写相应的 ==
方法。
如同我们亲眼所见,ID 也在表达关系时扮演着重要角色。
指定 Model 之间的关系
Active Record 支持三种表之间的关联关系,分别是一对一、一对多和多对多。你可能在 model 中添加相应的声明表述相应关系,分别是 has_one
、has_many
、belongs_to
和 has_and_belongs_to_many
。
一对一关系
一对一关系(更多情况下被叫做一对零或对一关系)是通过表中数据的存储外键,通过外键关联其他表的最多一条数据的情况。这种关系在订单和发票间会发生,通常情况下一个订单最多有一张发票。
如图所示,在 Rails 应用中我们在 Order model 中添加了 has_one
声明,在 Invoice model 中添加了 belongs_to
声明。
图示中还展示了一个重要的规则,一般情况下拥有 belongs_to
声明的 model 对应表为拥有外键的一方。
一对多关系
通过一对多关系可以处理集合对象。比如,一个订单可能含有多个购买商品。在数据库中,所有属于订单的购买商品数据都含有指向订单的外键。
在 Active Record 中,父对象(也就是逻辑上包含集合对象的对象)通过 has_many
声明与子表的关系,而子表通过 belongs_to
指向父表。在上述例子中,LineItem 类 belongs_to :order
,而 orders 表 has_many :line_item
。
需要再次注意,因为购买商品数据包含外键,所以它拥有 belongs_to
声明。
多对多关系
最后我们需要对商品进行分类。一件商品可以属于多个分类,一个分类也可以包含多个商品,这就是一个多对多的例子。每一方都可能包含另一方的数据集合。
在 Rails 中我们可以通过向相关的 model 都添加 has_and_belongs_to_many
声明表述这种关系。
多对多是一种对称关系,相互连接的表都通过 habtm 声明了它们之间的关系。
Rails 是通过中间表实现多对多关系的,中间表包含了连接两个目标表的外键。Active Record 默认将目标表名字按字母排序连接形成中间表名称。在上述例子中,我们将 categories 表与 products 表组合,所以 Active Record 将中间表取名为 categories_products。
我们也可以直接定义中间表名。在 Depot 中,Products 与 Carts 或 Orders 表结合形成 LineItems。自定义中间表的方式给了我们向其添加属性的能力,比如此例子中 LineItems 的 quantity 属性。
现在我们已经了解了关系数据定义的知识,你自然会希望能够访问数据库的数据,所以接下来我们将介绍相关知识。
创建、读取、更新及删除(CRUD)
无论是 SQLite 还是 MySQL 都是通过 Structure Query Language(SQL)访问数据库。Rails 会为你处理 SQL,但你也可以决定由自己处理。就如你所理解的一样,你完全可以编写 SQL 语句交由数据库执行。
如果你对 SQL 已经熟悉,阅读本节时你只需要注意 Rails 提供了哪些方式替换 SQL 语法,比如 select
,from
,where
,group by
等等。如果你对 SQL 尚不熟悉,Rails 的优势可以让你暂缓对相关知识的了解,当你真正需要通过 SQL 访问数据库时再学习。
在本节中,我们将以 Depot 中的 Order model 作为例子讲解,然后通过 Active Record 方法完成创建、读取、更新和删除这四种基本的数据库操作。
创建新数据
Rails 通过类表现数据库的表,而使用对象表示一条数据,按照这种类比方式,想在表中创建一条数据时就需要按相应的类创建对象。只要调用 Order.new()
方法即可创建表示 orders 表的新数据,然后再将数据填充至对象的属性中(对应的就是数据库中的列)。最后再通过对象的 save()
方法将订单存储至数据库中,如果没有调用 save()
方法,订单数据只会存在于本地内存中。
an_order = Order.new
an_order.name = "Dave Thomas"
an_order.email = "[email protected]"
an_order.address = "123 Main St"
an_order.pay_type = "check"
an_order.save
Active Record 构造器可以接收 block。如果传递 block 作为参数,block 中可以填写创建订单时的数据。这种方式在你想创建和存储订单而不想创建相应的局部变量时十分有用。
Order.new do |o|
o.name = "Dave Thomas"
#...
o.save
end
最后 Active Record 构造器也接收 hash 属性值作为参数。每一对 hash 数据都表示将数据填充至 key 表示的属性,在将 HTML 表单存储至数据库时这种方式格外有效。
an_order = Order.new(
name: "Dave Thomas",
email: "[email protected]",
address: "123 Main St",
pay_type: "check")
an_order.save
需要注意的是,在上述所有例子中我们都没有为新建数据设置 id 属性的值。因为 Active Record 默认情况下会为主键设置唯一整型数值。随后我们可以通过属性值查找数据。
an_order = Order.new
an_order.name = "Dave Thomas"
#...
an_order.save
puts "The ID of this order is #{an_order.id}"
new()
构造方法在内存中创建了一个新的 Order 对象,所以必须记住要将它存储至数据库。不过 Active Record 提供了一个更加方便的方法 create()
,它会将 model 对象实例化并存储至数据库中。
an_order = Order.create(
name: "Dave Thomas",
email: "[email protected]",
address: "123 Main St",
pay_type: "check")
你也可以向 create()
方法传递成数组的 hash 值,此方法将会在数据库中创建多条数据,并返回相应的 model 对象数组。
orders = Order.create([
{ name: "Dave Thomas",
email: "[email protected]",
address: "123 Main St",
pay_type: "check"
},
{ name: "Andy Hunt",
email: "[email protected]",
address: "456 Gentle Drive",
pay_type: "po"
}
])
new()
和 create()
方法接收 hash 数据的原因都是方便可以直接从表单数据构造 model 对象。
@order = Order.new(order_params)
如果你觉得上面这行代码看起来十分熟悉,那是因为你之前就见过它。在 Depot 的 orders_controller.rb 中它就曾出现过。
读取已存在的数据
如果想从数据库中读取关注的数据就需要向 Active Record 提供相应的条件,接着它就会返回符合相应条件的数据对象。
查找数据最简单的方法就是通过主键。每个 model 类都支持 find()
方法,它接收一个或多个主键参数。如果提供一个主键参数,方法会返回相应的一条数据对象(或者抛出 ActiveRecord::RecordNotFound 异常)。如果提供多个主键参数,find()
方法将返回符合条件的一组对象。要注意,在本示例中 RecordNotFound 异常会在提供的 ID 不存在时被抛出(所以如果方法没有抛出异常表示返回的数组长度与传递的主键数量一致)。
an_order = Order.find(27) # find the order with id == 27
# Get a list of product ids from a form, then
# find the associated Products
product_list = Product.find(params[:product_ids])
通常情况下除了基于主键获取数据的方式之外,你还会有通过其他条件查询的需求,而 Active Record 提供了其他方法供你通过更加复杂的条件查询。
SQL 和 Active Record
举例说明一下 Active Record 如何转换 SQL,当我们向 where()
方法传递字符串作为条件时就相当于调用了 SQL 的 where 语法。比如,查找 Dave 的所有支付类型为 po 的订单,我们可以使用下列方式:
pos = Order.where("name = 'Dave' and pay_type = 'po'")
David 提问:抛出或不抛出异常?
当你使用一个通过主键驱动的查询器时,你便可以查找到指定的数据,也就是说你期望它是存在的。
Person.find(5)
就是关于 people 表的这种表达,也就是我们需要 ID 为 5 的数据。如果方法调用失败,也许 ID 为 5 的记录被删除,这就是一种异常情况。这种情况会交由异常处理,所以 Rails 将抛出 RecordNotFound 异常。另一方面,查询器通过条件查找匹配的数据,所以
Person.where(name: 'Dave').first
等同于告诉数据库(可以将其看作黑盒)「给我第一个叫做 Dave 的人的数据」。这里展示的是另一种检索方式,我们不需要预先确定自己会获得一个结果,当前的结果集完全可能是空的。因此,当用于单个数据的查询器返回 nil,用于多条数据的查询器返回空数组都是十分正常的,它们并不会进行异常形式的回应。
返回结果将是包含所有条件的 ActiveRecord::Relation 对象数据,每一个对象都包含了一个 Order 对象。
如果查询条件已经被预定义的话更好,不过在客户名称被明确设置的情况下我们要如何处理它(或许数据来自 web 表单)?一种方式是将变量值替换至条件字符串中。
# get the name from the form
name = params[:name]
# DON't DO THIS!!!
pos = Order.where("name= '#{name}' and pay_type = 'po'")
就如同注释建议的一样,这种方式并不好。为什么?因为它为 SQL 侵入攻击创造了条件,在 265 页生成的 Rails Guides 中有更加详细的描述。同理,我们可以认为将外部数据直接替换 SQL 语句条件相当于向外界直接暴露整个数据库。
相比之下,较安全的方式是通过动态 SQL 的方式让 Active Record 处理它。这样做是让 Active Record 创建适当的 SQL,对 SQL 侵入攻击免疫。让我们看看要如何处理。
如果我们向 where()
传递多个参数,Rails 会将第一个参数作为生成 SQL 的模板。在 SQL 中,我们可以嵌入占位符,它将在运行时被数据余下的数据替换。
指定占位符的一种方式是在 SQL 中插入问号。第一个问号由数组的第二个元素替换,下一个问号则由第三个元素替换等等。比如,我们按如下方式重写上述查询语句:
name = params[:name]
pos = Order.where(["name= ? and pay_type= 'po'", name])
占位符也可以使用命名式的,通过将占位符替换为 :name
这种形式实现,不过这种方式需要提供相应的 hash 键值对实现。
name = params[:name]
pay_type = params[:pay_type]
pos = Order.where("name = :name and pay_type = :pay_type",
pay_type: pay_type, name: name)
我们还可以更进一步。因为 params 本身就是一种高效的 hash,所以我们也可以将它完全作为参数传入。如果有一个表单是用于输入查询条件的,便可以直接使用来自表单的 hash 值。
pos = Order.where("name = :name and pay_type = :pay_type", params[:order])
当然还可以更加简化。如果我们只是将 hash 作为参数,Rails 会将 hash 的键作为列名,而 hash 值作为查询匹配的值。所以将之前的代码简化之后如下:
pos = Order.where(params[:order])
使用上一种条件查询方式需要小心,因为它将所有的键值对都作为匹配条件传入了。而另一种方法可以使查询参数更加明确。
pos = Order.where(name: params[:name],
pay_type: params[:pay_type])
不论你使用的是哪种占位符,Active Record 都可以处理妥善,并将查询条件值替换至 SQL 中。使用这种方式的动态 SQL,Active Record 将使你免于 SQL 侵入攻击。
使用 Like 语法
我们常常会按下面代码一样在查询条件中使用 like 语法。
# Doesn't work
User.where("name like '?%'", params[:name])
Rails 并不会将此 SQL 转换为条件,也不会懂得将姓名值替换至字符串中。最终,它可能会在 name 参数值的周围添加引号。正确的做法应该是构造完整的 like 语法,并将参数传入条件中。
# Works
User.where("name like ?", params[:name]+"%")
当然,如果这样处理便需要考虑百分符号,它们将出现在 name 参数值的周围,被视作通配符处理。
构建返回记录
如今,我们已经知道了如何指定查询条件,接着让我们转换视线,来看看 ActiveRecord::Relation 支持的方法,首先从 first()
和 all()
开始。
如同你猜想的一样,first()
将返回 relation 中的第一条数据,如果 relation 为空将返回 nil。to_a()
也是类似的,它将把所有数据转换为数组返回,ActiveRecord::Relation 也支持许多 Array 对象的方法,比如 each()
和 map()
,在调用这两个方法时一般会在后台先调用 all()
。
要清晰地认识到这并不等同于查询。这些方法只是允许我们通过一些方式改变查询的结果,也就是在调用这些方法前调用其他方法。现在让我们看看这些方法。
order
SQL 并不会指定数据以某种特定排序返回,除非我们在查询时明确添加了 order by
语法。order()
方法允许我们在其中添加用于 order by
的条件。比如,下面的查询会返回所有 Dave 的订单,然后根据支付类型和购买日期排序(购买日期是使用降序排序)。
orders = Order.where(name: 'Dave').
order("pay_type, shipped_at DESC")
limit
通过 limit()
方法我们可以限制返回的数据数量。一般情况下,当使用限制方法时也会使用排序方法,以保证查询结果的一致性。比如,下列将返回前十个匹配的订单:
orders = Order.where(name: 'Dave').
order("pay_type, shipped_at DESC").
limit(10)
offset
offset()
方法与 limit()
方法是紧密相关的,它可以指定返回数据从第一行开始的偏移量。
# The view wants to display orders grouped into pages,
# where each page shows page_size orders at a time.
# This method returns the orders on page page_num (starting
# at zero)
def Order.find_on_page(page_num, page_size)
order(:id).limit(page_size).offset(page_num * page_size)
end
通过 offset 和 limit 结合可以在结果中查询 n 行数据。
select
默认情况下 ActiveRecord::Relation 将获取数据库表中所有列的数据,相当于 select * from ...
。通过 select()
方法可以用字符串替换 select 语法中的 *
。
此方法让我们可以获取表中数据的子集。比如,podcasts 表包含了 title、speaker 和 date 信息,也含有占大量存储的 BLOB 类型的 MP3 数据。如果你只是想创建聊天列表,将每条数据的声音数据加载出来将降低效率,而 select()
能让我们选择自己需要加载的数据列。
list = Talk.select("title, speaker, recorded_on")
joins
joins()
方法通过指定其他表而让基加入默认表中。插入 SQL 的参数处于 model 表名之后,第一个参数的指定条件之前,而且 join 语法是数据库特有的。下列代码将返回名为「Programming Ruby」的购买商品列表。
LineItem.select('li.quantity').
where("pr.title = 'Programming Ruby 1.9'").
joins("as li inner join products as pr on li.product_id = pr.id")
readonly
readonly()
方法将使 ActiveRecord::Resource 返回的 Active Record 对象无法被存储回数据库中。
如果我们是通过 joins()
或 select()
方法获得的结果,这些结果将自动被标记为只读。
group
group()
方法会向 SQL 添加 group by
语法。
summary = LineItem.select("sku, sum(amount) as amount").
group("sku")
lock
lock()
方法可以接收一个字符串参数。如果我们向其传递字符串,它将形成数据库语法中的一个 SQL 片段,用于指定某个类型的锁。比如,在 MySQL 中,共享锁只向我们提供一行中最后一个版本的数据,用于防止在我们获取数据时有人进行修改。我们将编写一段代码在账户余额充足的情况下进行取款,如下所示:
Account.transaction do
ac = Account.where(id: id).lock("LOCK IN SHARE MODE").first
ac.balance -= amount if ac.balance > amount
ac.save
end
如果我们没有向 lock()
方法传递字符串参数,而是传递 true
,数据库默认会使用排他锁(通常情况下用于更新操作)。我们通常使用事务消除这种关于锁的需求。
数据库还可以做许多基础查询及可依赖的数据检索,同时也可以做一些数据简化分析。Rails 也提供了用于此功能的方法。
字段统计及计算
Rails 也能够对字段进行统计计算。比如,对于订单表我们可以进行下列计算:
average = Order.average(:amount)
max = Order.maximum(:amount)
min = Order.minimum(:amount)
total = Order.sum(:amount)
number = Order.count
尽管这些都是数据库聚合函数,但它们都在独立于数据库的环境中运行。
这些方法也可以与之前讲解的知识点结合运用。
Order.where("amount > 20").minimum(:amount)
上述函数对数据进行了聚合处理,一般情况下,聚合函数会返回一个结果。例如,生成符合符合某些条件的订单的最小数值。不过,如果你使用了 group 方法就将得到一组结果,每个结果都是按分组条件聚合的数值。比如,下面计算计算每种状态的销售金额:
result = Order.group(:state).maximum(:amount)
puts result #=> {"TX"=>12345, "NC"=>3456, ...}
在上述代码返回的 hash 结果中,你可以通过分组元素定位相应数值(在此例中是 "TX","NC",....)。你也可以通过 each()
方法遍历每条数据,每条数据的值都是聚合函数的结果。
进行分组操作时 order
方法和 limit
方法也可以结合使用。
result = Order.group(:state)
order("max(amount) desc")
limit(3)
这段代码并不是独立于数据库的,为了对聚合列进行排序必须在聚合函数中使用 SQLite 语法(本例中是使用 max
)。
Scopes
由于方法的链式调用可能导致方法链过长,所以链式调用的重用成为一个值得关注的问题,而这种情况 Rails 也考虑到了。一个 Active Record scope 可以与一个 Proc 关联,所以有如下讨论:
class Order < ActiveRecord::Base
scope :last_n_days, lambda { |days| where('updated < ?', days) }
end
比如这个 scope 就可以轻松用于查找最后一周的订单。
orders = Order.last_n_days(7)
更简化的 scope 还可以省略参数。
class Order < ActiveRecord::Base
scope :checks, -> { where(pay_type: :check) }
end
scope 当然可以结合使用,查找最后一周通过 check 方式支付的订单就可以如下编写:
orders = Order.checks.last_n_days(7)
这种方式可以提高代码的可读性,书写也方便,而且也可以让代码更加高效。例如前面的示例中就可以将其作为一个单独的 SQL 查询进行处理。
ActiveRecord::Relation 就相当于一个匿名 scope。
in_house = Order.where('email LIKE "%@pragprog.com"')
按上述理论 relation 也可以与 scope 结合使用。
in_house.checks.last_n_days(7)
scope 并没有限制任何 where 条件,我们依然可以对 limit
、order
和 join
进行调用。不过要注意 Rails 并不了解如何处理多个 order 或 limit 语句,所以要确保在每个调用链中只使用一次。
在最近的示例中,我们对相应的方法都做了充足的描述。但 Rails 并不满足于这些示例的功能,有些情况需要亲自编制查询语句,Rails 也提供了相关的 API。
自己编写 SQL
我们看到的每个方法都提供了完全使用 SQL 语句的相关 API。find_by_sql()
可以让我们的应用完全进行控制,此方法只接收一个含有 select
SQL 语法的参数(或者一组包含 SQL 和占位符数据的参数,就像 find()
一样),并且返回一组 model 对象(也可能是空数组)。model 结果中的属性由查询结果的相应字段值填充。通常我们会使用 select *
查询所有列,但这里不需要。
orders = LineItem.find_by_sql("select line_items.* from line_items, orders where order_id = orders.id and orders.name = 'Dave Thomas'")
只有查询返回的属性对于 model 对象才是可用的。通过 attributes()
、attribute_names()
和 attribute_present?()
方法可以对 model 对象中的属性是否是否可用进行判断。第一个方法返回的是一对属性名称和数值的 hash 键值对,第二个方法返回的是一组名称,如果在 model 对象中指定的属性名称可用的话最后一个方法将返回 true
。
orders = Order.find_by_sql("select name, pay_type from orders")
first = orders[0]
p first.attributes
p first.attribute_names
p first.attribute_present?("address")
上述代码将输出下列结果:
{"name"=>"Dave Thomas", "pay_type"=>"check"}
["name", "pay_type"]
false
find_by_sql()
也可以根据原生字段创建 model 对象。如果利用 as xxx
SQL 语法向原生字段提供一个结果集名称,那这个名字也将是属性名称。
items = LineItem.find_by_sql("select *," +
" products.price as unit_price," +
" quantity*products.price as total_price, "+
" products.title as title " +
"from line_items, products " +
"where line_items.product_id = products.id")
li = items[0]
puts "#{li.title}: #{li.quantity}x#{li.unit_price} => #{li.total_price}"
我们也可以通过数组的方式向 find_by_sql()
传递参数,不过第一个参数是带有占位符的字符串,数组的其他元素可以可以是 hash 数据也可以是数组集合,不过都是用于替换占位符。
Order.find_by_sql(["select * from orders where amount > ?", params[:amount]])
在旧版 Rails 中,大家通常使用 find_by_sql()
。不过时至今日,所有的功能都已经附加至 find()
方法,我们不必再使用这些低级别的方法了。
重载数据
应用中的数据库存在被多个进程访问的可能(或者被多个应用访问),所以有可能获得的 model 对象并不是最新数据,很有可能其他进程已经向数据库写入了新数据。
David 提问:但难道 SQL 不会污染代码吗?
通常开发人员都会使用面向对象的方式映射关系型数据库,其中被讨论最多的问题是如何更高度地进行抽象。一些对象关系映射想完全去除使用 SQL,希望通过面向对象层就解决所有的请求。
但 Active Record 并不是这样想的,它基于 SQL 并不会污染代码或者 SQL 是不好的这种概念,只是在某些情况下会导致代码的冗长。所以我们应该关注的是去除这些导致冗长的语法的需求(亲手编写
insert
的十个参数将使程序员十分疲惫),但同时也要为复杂的查询保留相应的表达方式,SQL 就是用于完美解决这些复杂的查询。所以当你使用
find_by_sql()
处理性能瓶颈或复杂的查询时不要内疚。为了高效且愉快的编写代码请开始使用面向对象的接口,而在你真正需要手写 SQL 时再亲自学习和了解。
一般情况下这种问题都是通常事务处理(如同在 304 页描述的一样)。不过到时需要手动刷新 model 对象,Active Record 此方式进行了简化,只要调用 reload()
方法对象的属性将会从数据库中刷新。
stock = Market.find_by(ticker: "RUBY")
loop do
puts "Price = #{stock.price}"
sleep 60
stock.reload
end
实际生产中 reload()
很少在单元测试的环境之外使用。
更新已存在的数据
在大量关于查询方法的讨论之后,你肯定很乐意学习并不多的关于数据更新的知识。
如果你拥有 Active Record 对象(可以是 orders 表的一条数据),通过 save()
方法便可以将其写入数据库。如果此对象之前已经是从数据库中读取的,保存操作将更新已经存在的数据,否则保存操作会插入新的数据。
如果是将已经存在的数据更新,Active Record 会通过它的主键匹配内存中的对象。Active Record 对象中的属性将决定哪些字段会被更新,在数据库中字段被更新意味着它的值被修改。在下面的例子中,数据库中订单 123 的所有值将被更新:
order = Order.find(123)
order.name = "Fred"
order.save
但是在下面的例子中,Active Record 只包括了 id、name 和 paytype 属性,当对象被保存时只有这几个字段会被修改。(如果你通过 find_by_sql()
保存查询的数据时,需要在 SQL 中包含 id)。
orders = Order.find_by_sql("select id, name, pay_type from orders where id=123")
first = orders[0]
first.name = "Wilma"
first.save
除了 save()
方法外,我们也可以通过 update()
方法修改属性值并保存。
order = Order.find(321)
order.update(name: "Barney", email: "[email protected]")
update()
方法常常在 controller 中使用,特别是将来自表单的数据向已经存在数据库的数据整合时。
def save_after_edit
order = Order.find(params[:id])
if order.update(order_params)
redirect_to action: :index
else
render action: :edit
end
end
我们可以将查询数据和将其更新的方法 update()
、update_all()
结合使用。update()
方法接收参数 id 和一组属性。它将获取相应的数据,然后设置传递的属性值,接着将其存储至数据库,最后返回 model 对象。
order = Order.update(12, name: "Barney", email: "[email protected]")
向 update()
传递的参数也可以是一组 ID 和一组属性的 hash 数据,接着将更新数据库中相应的数据,然后返回一组 model 对象。
update_all()
方法允许我们指定 update SQL 语句中的 set
和 where
子句。比如下面这个例子,将标题中包含 Java 的商品价格提高百分之十:
result = Product.update_all("price = 1.1*price", "title like '%Java%'")
update_all()
返回的值依赖于数据库适配器,多数情况下(但不包括 Oracle)会返回数据库中被修改数据的行数。
save、save!、create 及 create!
其实 save
和 create
方法有两个版本的方法。主要的不同是它们报错的方式有所区别。
如果数据正常保存
save
方法将返回true
,否则就返回nil
。如果保存成功
save!
方法将返回true
,否则会抛出异常。无论是否成功保存
create
方法都将返回 Active Record 对象。如果你想确认数据是否已经被更新就需要验证对象的错误检测信息。当保存成功时
creat!
将返回 Active Record 对象,否则将抛出异常。
接着看看其他相关详情。
如果 model 对象是有效的且被正常存储常规版本的 save()
将返回 true
。
if order.save
# all OK
else
# validation failed
end
检查 save()
的调用结果是否为我们期望的也很需要。有些原因导致 Active Record 是对此的使用如此宽泛,它通常认为 save()
方法会在 controller 的 action 方法上下文中调用,而 view 将向终端用户显示返回的错误。在许多应用中,这样的场景都十分常见。
不过,如果我们是希望在能确认处理所有异常的情况下保存 model 对象就应该使用 save()
。如果对象不能正常存储就会抛出 RecordInvalid 异常。
begin
order.save!
rescue RecordInvalid => error
# validation failed
end
删除数据
Active Record 支持两种风格的数据删除。首先,它拥有两种类级别的删除方法,分别是 delete()
和 delete_all()
,这些都是数据库级别的删除。delete()
方法接受一个参数 ID 或一组 ID,然后将删除数据库表中的相应数据,delete_all()
将删除相应条件的数据(如果不指定条件将删除所有数据)。虽然调用的结果依赖于选择的适配器,但通常是受影响的数据行数。如果数据并不存在调用的先后顺序异常将不会被抛出。
Order.delete(123)
User.delete([2, 3, 4, 5])
Product.delete_all(["price > ?", @expensive_price])
由 Active Record 提供的另一种删除方法是 destroy
。这些方法要正常运行需要通过 model 对象调用。
destroy()
实例方法会从数据库删除 model 对象相应的数据。接着它将冻结对象的内容,避免属性被修改。
order = Order.find_by(name: "Dave")
order.destroy
# ... order is now frozen
不过还有两个类级别的删除方法,destroy()
(接收一个 ID 或一组 ID)和 destroy_all()
(接收条件)。这两个方法都是将相应数据从数据库读取至 model 对象中,然后再调用 model 对象的 destroy()
方法。这两个方法也不会返回任何有意义的值。
Order.destroy_all(["shipped_at < ?", 30.days.ago])
为什么类级别的 delete
和 destroy
方法都需要呢?delete
会绕过 Active Record 回调及验证方法,而 destroy
会保证这些方法都被执行。所以当你想保证数据库中数据与 model 类中定义的规则一致的话最好使用 destroy
方法。
在 77 页的内容我们已经讲过验证,后面对回调会进行讲解。
参与流程监控
Active Record 负责管理 model 对象的生命周期,Active Record 会创建 model 对象,也在它们被修改、保存或更新时进行管理,在看到它们被删除时也会感到悲伤。只要通过回调我们的代码也可以参与到 model 对象的管理流程中。在对象的生命周期中任何重要事件都可以调用我们编写的代码。通过回调我们可以执行复杂的验证,在数据进入或输出数据库时匹配字段值,以此保证操作的完整性。
Active Record 定义了 16 个回调函数,其中的 14 个函数是对象生命周期事件的前后成对的方法。比如,before_destroy
会在 destroy()
方法前被调用,而 after_destroy()
会在 destroy()
方法后被调用。另外两个特别的方法是 after_find
和 after_initialize
,它们都没有相应的 before 方法。(这两个回调函数与其他的也不同,稍后我们会继续讨论)。
下列图示中我们可以看到 Rails 如何包装围绕在创建、更新和删除这些基础操作周围周围的 16 个成对的回调方法。甚至你不会惊讶惊讶地发现前后验证的调用并不是严格的内嵌方式。
before_validation
和 after_validation
调用方法也接收 on: :create
和 on: :update
参数,此参数表示回调后触发选择的操作。
图中的 16 个调用方法中,after_find
将在任意查询后触发,after_initialize
将在 model 对象被创建后触发。
如果需要回调方法调用你的代码,你必须编写一个处理器并将它关联至合适的回调事件。
下面是两种基本的实现回调方式。
我们更加推荐定义回调方法的方式声明处理器,处理器可以是一个方法也可以是 block。通过在事件之后使用类方法名便可以将处理器与指定的事件绑定。对于绑定的方法需要声明为私有或受保护的,然后将它的名字作为处理器声明的标识。如果回调是一个 block,只需要将它添加在声明之后即可,而且 block 接收到的参数是 model 对象。
class Order < ActiveRecord::Base
before_validation :normalize_credit_card_number
after_create do |order|
logger.info "Order #{order.id} created"
end
protected
def normalize_credit_card_number
self.cc_number.gsub!(/[-\s]/, '')
end
end
你也可以对同一个回调事件定义多个处理器。如果没有处理器返回 false
它们通常会按指定顺序执行(处理器必须返回实际值是 false
),当处理器返回 false
时回调链将提前断裂。
而且你也可以通过回调对象定义回调实例方法、内联方法(利用 proc 实现)或内联 eval 方法(利用 string 实现),可以通过在线文档了解更多细节。
将相关回调分组
如果你拥有一个关联回调的分组,就可以十分方便地将它们分隔到处理器内中。这些处理器会在多个 model 中共享使用。处理器类只是一个定义了回调方法的类(比如 before_save()
、after_create()
方法等),一般都在 app/models 路径中创建处理器类的源文件。
在使用处理器的 model 对象中,你创建了处理器类的实例,并将这些实例传递向各个回调声明中。下面会举两个例子说明。
如果我们的应用在多个地方使用信用卡,就会希望在多个 model 中共享 normalize_credit_card_number
方法。为了按这种方式实现,需要将这个方法提取至自己的类中,并在希望处理的事件后命名它。此方法会接收一个参数——由回调生成的 model 对象。
class CreditCardCallbacks
# Normalize the credit card number
def before_validation(mode)
model.cc_number.gsub!(/[-\s]/, '')
end
end
现在,在我们的 model 类中便可以共享调用这些回调方法。
class Order < ActiveRecord::Base
before_validation CreditCardCallbacks.new
#...
end
class Subscription < ActiveRecord::Base
before_validation CreditCardCallbacks.new
#...
end
在这个例子中,处理器类假设信用卡号在 model 中是命名为 cc_number
的属性,所以 Order 和 Subscription 都应该拥有这个名字的属性。不过我们还可以更加泛化这种方式,使处理器类减少与关联类实现细节的依赖。
例如,我们可以创建一个通用的加密和解密处理器。通过这个处理器,我们可以在将数据存入数据库前将其加密,取回时将其解密方便阅读。在任何需要此功能的 model 中都可以使用此回调处理器。
在 model 的数据被存储至数据库前处理器需要将 model 中指定的一组属性进行加密。由于应用还需要处理这些属性的普通文字版本,所以在存储完成后还需要将数据解密,而且当数据从数据库读取转化为 model 对象时也需要解密。这些需求意味着我们需要处理 before_save
、after_save
和 after_find
事件。因为在存储完数据后和查找到数据后都需要解密数据,所以我们通过给 after_save()
添加 after_find()
别名实现,同一个方法也可以有两个名字。
class Encrypter
def initialize(attrs_to_manage)
@attrs_to_manage = attrs_to_manage
end
def before_save(model)
@attrs_to_manage.each do |field|
model[field].tr!("a-z", "b-za")
end
end
def after_save(model)
@attrs_to_manage.each do |field|
model[field].tr!("b-za", "a-z")
end
end
alias_method :after_find, :after_save
end
上面例子中的加密比较简单,在真正使用前你应该想将它改造加强。
现在我们就可以在订单 model 内部使用 Encrypter
类。
require "encrypter"
class Order < ActiveRecord::Base
encrypter = Encrypter.new([:name, :email])
before_save encrypter
after_save encrypter
after_find encrypter
protected
def after_find
end
end
在 model 中,我们创建了一个 Encrypter 对象,并将它与 before_save
、after_save
和 after_find
方法关联。按照这种方法,在订单被保存之前 encrypter 中的 before_save()
方法将被调用,其他的回调类似。
不过,为什么我们要定义一个空 after_find()
方法?回忆一下之前说过 after_find
和 after_initialize
需要区别对待。
其中的一个特别之处就是如果 model 类没有真实存在 after_find()
方法 Active Record 就不知道要调用 after_find
处理器。所以我们必须定义一个空占位符方便 after_find
进行替换。
一切都已准备妥当,但每个 model 类想使用我们的加密处理器时都需要添加 8 行代码,就像我们在 Order 类中做的那样,不过我们还可以继续改进它。我们可以定义一个辅助方法处理这些工作,并且在所有的 Active Record 中共享使用。为了按这种方式实现,我们需要将辅助方法添加至 ActiveRecord::Base
类中。
class ActiveRecord::Base
def self.encrypt(*attr_names)
encrypter = Encrypter.new(attr_names)
before_save encrypter
after_save encrypter
after_find encrypter
define_method(:after_find) { }
end
end
上述提供的方法中,现在我们通过单独的调用便可以向任何 model 类的属性进行加密。
class Order < ActiveRecord::Base
encrypt(:name, :email)
end
一个简单的驱动程序就可以使我们更加简易地使用它。
o = Order.new
o.name = "Dave Thomas"
o.address = "123 The Street"
o.email = "[email protected]"
o.save
puts o.name
o = Order.find(o.id)
puts o.name
在控制台中,我们会看到 model 对象中客户的名字(按普通文字显示)。
ruby encrypt.rb
Dave Thomas
Dave Thomas
不过在数据库中,名字和邮箱地址会被我们强有力的加密方式掩藏起来。
sqlite3 -line db/development.sqlite3 "select * from orders"
回调是一种不错的技术,但有时这样会导致 model 承担与真实的 model 无关的职责。比如,在 298 页,我们创建了一个回调,当订单被创建时会生成日志日志信息。虽然这个函数并不应该是 Order 类的一部分,只是为了回调能正常执行所以将其放置在 Order 类中。
如果只是适度使用,这种方法也不会导致重大的问题,不过如果你一直使用这种方式便会发现自己在重复编写代码,此时可以考虑使用 Concerns 替换。
事务
数据库事务就是将一系统变化组织起来,使数据库在应用这些变化时要不都成功要不都失败。关于事务的需求其中一个经典的例子是在两个银行账户间转钱。基本的逻辑如下:
account1.deposit(100)
account2.withdraw(100)
不过我们需要小心。如果存款成功但由于某些原因转款失败(有可能是客户余额余额不足)会发生什么?参照上述代码,account1 将添加 100 美元,而 account2 并没有扣除相应的钱。这将导致我们凭空创造 100 美元。
在 Active Record 中我们通过 transaction()
方法在相应的数据库事务中执行 block。在 block 的结束时事务将被提交并更新数据库,除非在 block 中有异常被抛出,如果出现异常数据库此次事务中的所有变动都将回滚。由于事务存在于数据库连接的上下文中,我们必须在 Active Record 的接收器中使用。
所以,我们可以这样编写代码:
Account.transaction do
account1.deposit(100)
account2.withdraw(100)
end
让我们探索一下事务。首先要创建一个新的数据库表。(先确认你的数据库支持事务,否则代码并不能正常运行)。
create_table :accounts, force: true do |t|
t.string :number
t.decimal :balance, precision: 10, scale: 2, default: 0
end
接下来,我们要定义一个简单的账户类。类中还需要定义存钱和取钱的实例方法。同时也需要提供一些基础的验证,对于这些特殊类型的账户交易不能是负的。
class Account < ActiveRecord::Base
validates :balance, numbericality: {greater_than_or_equal_to: 0}
def withdraw(amount)
adjust_balance_and_save!(-amount)
end
def deposit(amount)
adjust_balance_and_save!(amount)
end
private
def adjust_balance_and_save!(amount)
self.balance += amount
save!
end
end
所以,现在让我们编写代码在两个账户之间转钱,而代码也是十分直截了当。
peter = Account.create(balance: 100, number: "12345")
paul = Account.create(balance: 200, number: "54321")
Account.transaction do
paul.deposit(10)
peter.withdraw(10)
end
我们检查一下数据库,并确认有足够的钱能够互相转账。
sqlite3 -line db/development.sqlite3 "select * from accounts"
现在我们转更多的钱,如果这次我们尝试转账 350 美元,Peter 将发生错误,而且验证规则也无法通过。让我们试试:
peter = Account.create(balance: 100, number: "12345")
paul = Account.create(balance: 200, number: "12345")
Account.transaction do
paul.deposit(350)
peter.withdraw(350)
end
当我们运行这段代码时,控制台中会报告一个异常。
.../validations.rb:736:in `save!': Validation failed: Balance is negativefrom transactions.rb:46:in `adjust_balance_and_save!'
: : :
from transactions.rb:8
查看数据库我们会发现数据并没有发生变化。
sqlite3 -line db/development.sqlite3 "select * from accounts"
不过还有个陷阱在等待你。事务可以防止数据库由于不一致引起的问题,但我们的 model 对象正常吗?仔细看看 model 对象发生的事情,我们必须处理异常让程序能够继续运行。
peter = Account.create(balance: 100, number: "12345")
paul = Account.create(balance: 200, number: "54321")
begin
Account.transaction do
paul.deposit(350)
peter.withdraw(350)
end
rescue
puts "Transfer aborted"
end
puts "Paul has #{paul.balance}"
puts "Peter has #{peter.balance}"
不过结果还是让人有点惊讶。
Transfer aborted
Paul has 550.0
Peter has -250.0
尽管数据库远离了危险,但 model 对象依然会被修改。这是因为 Active Record 不能追踪到多个对象在变化前后的状态,实际上要了解哪个 model 中包含事务并不是件容易的事。
构建事务
当我们在 282 页讨论父表及子表时,我们讲过 Active Record 会在保存父表数据时将相关的子表数据也同时存储。这种方式使用了多 SQL 声明执行(一个是父表数据的,其他的每一个都是子表数据的)。
当然这些修改都是原子的,但直到现在我们还没有通过事务保存相关的对象。是我们忽略了吗?
幸运的是,并没有,Active Record 会在事务中处理 save()
方法中所有相关的更新和插入(destroy()
也会处理相关的删除),它们要不就全部成功要不就永远无法将数据写入数据库。当你管理多个 SQL 声明时必须要有明确的事务。
当我们了解了基础知识后事务就显得十分精妙了。它们展现了叫做 ACID 的特性:它们是原子的,并且保证是一致的,而运行是独立的,以及影响是持久的(当事务提交后修改就是永久的)。如果你打算了解数据库应用的知识便需要找到一本优秀的数据库书籍并阅读事务相关相关章节。
总结
我们学习了相关的数据结构和表、类、字段、属性、id、关联关系的命令规范。同时也学习了如何创建、读取、更新和删除数据。最后,还学习了解了用于防止不一致的变动的事务和回调。
关于验证的知识在 77 页已经进行了表述,当时的讨论已经包含了 Rails 程序员需要了解的关于 Active Record 的所有要点。如果章节的知识并没有满足你的需求可以查看 265 页是生成的 Rails 指南,其中包含更多信息。
下章的主要内容是关于 Action Pack,其中覆盖了 Rails 的 view 和 controller 部分。
本文翻译自《Agile Web Development with Rails 4》,目的为学习所用,如有转载请注明出处。